make chats allow file uploading
This commit is contained in:
@@ -38,12 +38,12 @@ for %%T in (%BUILD_TARGETS%) do (
|
|||||||
|
|
||||||
echo [build] cl /LD /EHsc /O2 plugin.cpp /Fe:!OUTFILE!
|
echo [build] cl /LD /EHsc /O2 plugin.cpp /Fe:!OUTFILE!
|
||||||
pushd "%NATIVE_DIR%"
|
pushd "%NATIVE_DIR%"
|
||||||
cl /nologo /LD /EHsc /O2 plugin.cpp /Fe:"!OUTFILE!" user32.lib gdi32.lib uxtheme.lib dwmapi.lib /link !CL_MACHINE! >nul 2>&1
|
cl /nologo /LD /EHsc /O2 plugin.cpp /Fe:"!OUTFILE!" user32.lib gdi32.lib uxtheme.lib dwmapi.lib comdlg32.lib shell32.lib /link !CL_MACHINE! >nul 2>&1
|
||||||
set "CL_STATUS=!errorlevel!"
|
set "CL_STATUS=!errorlevel!"
|
||||||
popd
|
popd
|
||||||
if not "!CL_STATUS!"=="0" (
|
if not "!CL_STATUS!"=="0" (
|
||||||
echo [build] cl failed, trying !GXX_CMD!...
|
echo [build] cl failed, trying !GXX_CMD!...
|
||||||
!GXX_CMD! -shared -O2 -s -static -o "!OUTFILE!" "%NATIVE_DIR%\plugin.cpp" -luser32 -lgdi32 -luxtheme -ldwmapi
|
!GXX_CMD! -shared -O2 -s -static -o "!OUTFILE!" "%NATIVE_DIR%\plugin.cpp" -luser32 -lgdi32 -luxtheme -ldwmapi -lcomdlg32 -lshell32
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo [error] build failed for !TARGET_OS!-!TARGET_ARCH!
|
echo [error] build failed for !TARGET_OS!-!TARGET_ARCH!
|
||||||
exit /b 1
|
exit /b 1
|
||||||
|
|||||||
@@ -152,6 +152,13 @@
|
|||||||
background: #303848;
|
background: #303848;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-btn-icon {
|
||||||
|
padding: 8px 12px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* Status */
|
/* Status */
|
||||||
.chat-status {
|
.chat-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -233,6 +240,52 @@
|
|||||||
color: #dce1ea;
|
color: #dce1ea;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-msg-attachment {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-image {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 280px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #2a3346;
|
||||||
|
background: #0f131b;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-file {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0f131b;
|
||||||
|
border: 1px solid #2a3346;
|
||||||
|
color: #cfd6e2;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 12px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-file:hover {
|
||||||
|
background: #141a25;
|
||||||
|
border-color: #3a4a60;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-file i {
|
||||||
|
color: #8a96aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-file-name {
|
||||||
|
font-weight: 500;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg-file-size {
|
||||||
|
color: #6b7a8d;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-msg-time {
|
.chat-msg-time {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: #505a6a;
|
color: #505a6a;
|
||||||
|
|||||||
@@ -58,6 +58,8 @@
|
|||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<section class="chat-input-area">
|
<section class="chat-input-area">
|
||||||
|
<input type="file" id="file-input" hidden />
|
||||||
|
<button id="btn-attach" class="chat-btn chat-btn-muted chat-btn-icon" disabled title="Attach file (max 5 MB)"><i class="fa-solid fa-paperclip"></i></button>
|
||||||
<input type="text" id="msg-input" class="chat-field chat-msg-field" placeholder="Type a message..." disabled />
|
<input type="text" id="msg-input" class="chat-field chat-msg-field" placeholder="Type a message..." disabled />
|
||||||
<button id="btn-send" class="chat-btn chat-btn-blue" disabled>Send</button>
|
<button id="btn-send" class="chat-btn chat-btn-blue" disabled>Send</button>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
+155
-23
@@ -1,5 +1,7 @@
|
|||||||
(() => {
|
(() => {
|
||||||
const PLUGIN_ID = "chat";
|
const PLUGIN_ID = "chat";
|
||||||
|
const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024;
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
const clientIdInput = document.getElementById("client-id");
|
const clientIdInput = document.getElementById("client-id");
|
||||||
@@ -16,11 +18,14 @@
|
|||||||
const messagesEl = document.getElementById("messages");
|
const messagesEl = document.getElementById("messages");
|
||||||
const msgInput = document.getElementById("msg-input");
|
const msgInput = document.getElementById("msg-input");
|
||||||
const btnSend = document.getElementById("btn-send");
|
const btnSend = document.getElementById("btn-send");
|
||||||
|
const btnAttach = document.getElementById("btn-attach");
|
||||||
|
const fileInput = document.getElementById("file-input");
|
||||||
const configToggle = document.getElementById("config-toggle");
|
const configToggle = document.getElementById("config-toggle");
|
||||||
const configBody = document.getElementById("config-body");
|
const configBody = document.getElementById("config-body");
|
||||||
|
|
||||||
let chatOpen = false;
|
let chatOpen = false;
|
||||||
let sseStream = null;
|
let sseStream = null;
|
||||||
|
const attachmentUrlCache = new Map();
|
||||||
|
|
||||||
clientIdInput.value = params.get("clientId") || "";
|
clientIdInput.value = params.get("clientId") || "";
|
||||||
|
|
||||||
@@ -32,10 +37,11 @@
|
|||||||
chatOpen = open;
|
chatOpen = open;
|
||||||
statusDot.className = "chat-status-dot" + (open ? " open" : "");
|
statusDot.className = "chat-status-dot" + (open ? " open" : "");
|
||||||
statusText.textContent = open ? "Chat is open" : "Chat not opened";
|
statusText.textContent = open ? "Chat is open" : "Chat not opened";
|
||||||
btnOpen.disabled = open;
|
btnOpen.disabled = open;
|
||||||
btnClose.disabled = !open;
|
btnClose.disabled = !open;
|
||||||
msgInput.disabled = !open;
|
msgInput.disabled = !open;
|
||||||
btnSend.disabled = !open;
|
btnSend.disabled = !open;
|
||||||
|
btnAttach.disabled = !open;
|
||||||
if (open) msgInput.focus();
|
if (open) msgInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,32 +50,107 @@
|
|||||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(s) {
|
function humanSize(bytes) {
|
||||||
const d = document.createElement("div");
|
if (bytes < 1024) return bytes + " B";
|
||||||
d.textContent = s;
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
|
||||||
return d.innerHTML;
|
return (bytes / 1024 / 1024).toFixed(2) + " MB";
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendMessage(sender, text, direction, timestamp) {
|
function readFileAsBase64(file) {
|
||||||
const isOut = direction === "to_target";
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAttachmentBlob(id) {
|
||||||
|
if (attachmentUrlCache.has(id)) return attachmentUrlCache.get(id);
|
||||||
|
const { ok, result } = await rpc("get_attachment", { id });
|
||||||
|
if (!ok || !result?.dataB64) return null;
|
||||||
|
const bin = atob(result.dataB64);
|
||||||
|
const bytes = new Uint8Array(bin.length);
|
||||||
|
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||||
|
const blob = new Blob([bytes], {
|
||||||
|
type: result.mime || "application/octet-stream",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
attachmentUrlCache.set(id, url);
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachment(att, messageId) {
|
||||||
|
const wrap = document.createElement("div");
|
||||||
|
wrap.className = "chat-msg-attachment";
|
||||||
|
const isImage = (att.mime || "").startsWith("image/");
|
||||||
|
|
||||||
|
if (isImage) {
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "chat-msg-image";
|
||||||
|
img.alt = att.name;
|
||||||
|
img.title = `${att.name} (${humanSize(att.size)})`;
|
||||||
|
loadAttachmentBlob(messageId).then((url) => {
|
||||||
|
if (url) img.src = url;
|
||||||
|
});
|
||||||
|
img.addEventListener("click", async () => {
|
||||||
|
const url = await loadAttachmentBlob(messageId);
|
||||||
|
if (url) window.open(url, "_blank");
|
||||||
|
});
|
||||||
|
wrap.appendChild(img);
|
||||||
|
} else {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "chat-msg-file";
|
||||||
|
link.href = "#";
|
||||||
|
link.innerHTML = `<i class="fa-solid fa-file"></i> <span class="chat-msg-file-name"></span> <span class="chat-msg-file-size"></span>`;
|
||||||
|
link.querySelector(".chat-msg-file-name").textContent = att.name;
|
||||||
|
link.querySelector(".chat-msg-file-size").textContent = `(${humanSize(att.size)})`;
|
||||||
|
link.addEventListener("click", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const url = await loadAttachmentBlob(messageId);
|
||||||
|
if (!url) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = att.name;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
});
|
||||||
|
wrap.appendChild(link);
|
||||||
|
}
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMessage(msg) {
|
||||||
|
const isOut = msg.direction === "to_target";
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "chat-msg " + (isOut ? "outgoing" : "incoming");
|
div.className = "chat-msg " + (isOut ? "outgoing" : "incoming");
|
||||||
|
|
||||||
const senderDiv = document.createElement("div");
|
const senderDiv = document.createElement("div");
|
||||||
senderDiv.className = "chat-msg-sender";
|
senderDiv.className = "chat-msg-sender";
|
||||||
senderDiv.textContent = sender;
|
senderDiv.textContent = msg.sender;
|
||||||
|
div.appendChild(senderDiv);
|
||||||
|
|
||||||
const textDiv = document.createElement("div");
|
if (msg.text) {
|
||||||
textDiv.className = "chat-msg-text";
|
const textDiv = document.createElement("div");
|
||||||
textDiv.textContent = text;
|
textDiv.className = "chat-msg-text";
|
||||||
|
textDiv.textContent = msg.text;
|
||||||
|
div.appendChild(textDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.attachment) {
|
||||||
|
div.appendChild(renderAttachment(msg.attachment, msg.id));
|
||||||
|
}
|
||||||
|
|
||||||
const timeDiv = document.createElement("div");
|
const timeDiv = document.createElement("div");
|
||||||
timeDiv.className = "chat-msg-time";
|
timeDiv.className = "chat-msg-time";
|
||||||
timeDiv.textContent = formatTime(timestamp || Date.now());
|
timeDiv.textContent = formatTime(msg.timestamp || Date.now());
|
||||||
|
|
||||||
div.appendChild(senderDiv);
|
|
||||||
div.appendChild(textDiv);
|
|
||||||
div.appendChild(timeDiv);
|
div.appendChild(timeDiv);
|
||||||
|
|
||||||
messagesEl.appendChild(div);
|
messagesEl.appendChild(div);
|
||||||
messagesEl.scrollTop = messagesEl.scrollHeight;
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
||||||
}
|
}
|
||||||
@@ -103,10 +184,10 @@
|
|||||||
if (!cid) return;
|
if (!cid) return;
|
||||||
const { ok, result } = await rpc("get_history", { clientId: cid });
|
const { ok, result } = await rpc("get_history", { clientId: cid });
|
||||||
if (!ok || !result) return;
|
if (!ok || !result) return;
|
||||||
|
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
||||||
|
attachmentUrlCache.clear();
|
||||||
messagesEl.innerHTML = "";
|
messagesEl.innerHTML = "";
|
||||||
for (const m of result) {
|
for (const m of result) appendMessage(m);
|
||||||
appendMessage(m.sender, m.text, m.direction, m.timestamp);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSSE() {
|
function connectSSE() {
|
||||||
@@ -116,7 +197,7 @@
|
|||||||
sseStream.addEventListener("new_message", (e) => {
|
sseStream.addEventListener("new_message", (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.clientId !== getClientId()) return;
|
if (data.clientId !== getClientId()) return;
|
||||||
appendMessage(data.sender, data.text, data.direction, data.timestamp);
|
appendMessage(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
sseStream.addEventListener("chat_status", (e) => {
|
sseStream.addEventListener("chat_status", (e) => {
|
||||||
@@ -128,6 +209,8 @@
|
|||||||
sseStream.addEventListener("history_cleared", (e) => {
|
sseStream.addEventListener("history_cleared", (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.clientId !== getClientId()) return;
|
if (data.clientId !== getClientId()) return;
|
||||||
|
for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url);
|
||||||
|
attachmentUrlCache.clear();
|
||||||
messagesEl.innerHTML = "";
|
messagesEl.innerHTML = "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -177,7 +260,12 @@
|
|||||||
await rpc("store_message", { clientId: cid, sender, text });
|
await rpc("store_message", { clientId: cid, sender, text });
|
||||||
await sendPluginEvent(cid, "chat_message", { from: sender, text });
|
await sendPluginEvent(cid, "chat_message", { from: sender, text });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendMessage("System", "Failed to send: " + err.message, "from_target", Date.now());
|
appendMessage({
|
||||||
|
sender: "System",
|
||||||
|
text: "Failed to send: " + err.message,
|
||||||
|
direction: "from_target",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,6 +276,50 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const mime = file.type || "application/octet-stream";
|
||||||
|
await rpc("store_attachment", {
|
||||||
|
clientId: cid,
|
||||||
|
sender,
|
||||||
|
name: file.name,
|
||||||
|
mime,
|
||||||
|
dataB64,
|
||||||
|
});
|
||||||
|
await sendPluginEvent(cid, "chat_attachment", {
|
||||||
|
from: sender,
|
||||||
|
name: file.name,
|
||||||
|
mime,
|
||||||
|
dataB64,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
appendMessage({
|
||||||
|
sender: "System",
|
||||||
|
text: "Failed to send file: " + err.message,
|
||||||
|
direction: "from_target",
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
btnAttach.disabled = prevDisabled;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
btnClear.addEventListener("click", async () => {
|
btnClear.addEventListener("click", async () => {
|
||||||
const cid = getClientId();
|
const cid = getClientId();
|
||||||
if (!cid) return;
|
if (!cid) return;
|
||||||
|
|||||||
+322
-21
@@ -2,23 +2,27 @@
|
|||||||
* Chat Plugin — spawns a Win32 chat window on the target (Windows only).
|
* Chat Plugin — spawns a Win32 chat window on the target (Windows only).
|
||||||
*
|
*
|
||||||
* Build (MSVC):
|
* Build (MSVC):
|
||||||
* cl /LD /EHsc /O2 plugin.cpp /Fe:chat-windows-amd64.dll user32.lib gdi32.lib uxtheme.lib dwmapi.lib
|
* cl /LD /EHsc /O2 plugin.cpp /Fe:chat-windows-amd64.dll user32.lib gdi32.lib uxtheme.lib dwmapi.lib comdlg32.lib shell32.lib
|
||||||
*
|
*
|
||||||
* Build (MinGW):
|
* Build (MinGW):
|
||||||
* x86_64-w64-mingw32-g++ -shared -O2 -o chat-windows-amd64.dll plugin.cpp -luser32 -lgdi32 -luxtheme -ldwmapi
|
* x86_64-w64-mingw32-g++ -shared -O2 -o chat-windows-amd64.dll plugin.cpp -luser32 -lgdi32 -luxtheme -ldwmapi -lcomdlg32 -lshell32
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <cctype>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#define EXPORT extern "C" __declspec(dllexport)
|
#define EXPORT extern "C" __declspec(dllexport)
|
||||||
#define WIN32_LEAN_AND_MEAN
|
#define WIN32_LEAN_AND_MEAN
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include <uxtheme.h>
|
#include <uxtheme.h>
|
||||||
#include <dwmapi.h>
|
#include <dwmapi.h>
|
||||||
|
#include <commdlg.h>
|
||||||
|
#include <shellapi.h>
|
||||||
|
|
||||||
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||||
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
#define DWMWA_USE_IMMERSIVE_DARK_MODE 20
|
||||||
@@ -34,9 +38,12 @@
|
|||||||
#define WM_CHAT_APPEND (WM_APP + 1)
|
#define WM_CHAT_APPEND (WM_APP + 1)
|
||||||
#define WM_CHAT_CLOSE (WM_APP + 2)
|
#define WM_CHAT_CLOSE (WM_APP + 2)
|
||||||
|
|
||||||
#define IDC_LOG 101
|
#define IDC_LOG 101
|
||||||
#define IDC_INPUT 102
|
#define IDC_INPUT 102
|
||||||
#define IDC_SEND 103
|
#define IDC_SEND 103
|
||||||
|
#define IDC_ATTACH 104
|
||||||
|
|
||||||
|
#define MAX_ATTACHMENT_BYTES (5 * 1024 * 1024)
|
||||||
|
|
||||||
static const char *WND_CLASS_NAME = "OverlordChatWnd";
|
static const char *WND_CLASS_NAME = "OverlordChatWnd";
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ static HWND g_hwnd = NULL;
|
|||||||
static HWND g_hwnd_log = NULL;
|
static HWND g_hwnd_log = NULL;
|
||||||
static HWND g_hwnd_input = NULL;
|
static HWND g_hwnd_input = NULL;
|
||||||
static HWND g_hwnd_send = NULL;
|
static HWND g_hwnd_send = NULL;
|
||||||
|
static HWND g_hwnd_attach = NULL;
|
||||||
static HANDLE g_thread = NULL;
|
static HANDLE g_thread = NULL;
|
||||||
static bool g_class_reg = false;
|
static bool g_class_reg = false;
|
||||||
static HFONT g_font = NULL;
|
static HFONT g_font = NULL;
|
||||||
@@ -157,16 +165,151 @@ static std::string json_escape(const std::string &s) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Base64 */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static const char kB64Tab[] =
|
||||||
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||||
|
|
||||||
|
static std::string base64_encode(const unsigned char *data, size_t len) {
|
||||||
|
std::string out;
|
||||||
|
out.reserve(((len + 2) / 3) * 4);
|
||||||
|
size_t i = 0;
|
||||||
|
while (i + 3 <= len) {
|
||||||
|
unsigned a = data[i], b = data[i + 1], c = data[i + 2];
|
||||||
|
out += kB64Tab[a >> 2];
|
||||||
|
out += kB64Tab[((a & 0x03) << 4) | (b >> 4)];
|
||||||
|
out += kB64Tab[((b & 0x0f) << 2) | (c >> 6)];
|
||||||
|
out += kB64Tab[c & 0x3f];
|
||||||
|
i += 3;
|
||||||
|
}
|
||||||
|
if (i < len) {
|
||||||
|
unsigned a = data[i];
|
||||||
|
unsigned b = (i + 1 < len) ? data[i + 1] : 0;
|
||||||
|
out += kB64Tab[a >> 2];
|
||||||
|
out += kB64Tab[((a & 0x03) << 4) | (b >> 4)];
|
||||||
|
if (i + 1 < len) {
|
||||||
|
out += kB64Tab[(b & 0x0f) << 2];
|
||||||
|
out += '=';
|
||||||
|
} else {
|
||||||
|
out += "==";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int b64_val(char c) {
|
||||||
|
if (c >= 'A' && c <= 'Z') return c - 'A';
|
||||||
|
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
|
||||||
|
if (c >= '0' && c <= '9') return c - '0' + 52;
|
||||||
|
if (c == '+') return 62;
|
||||||
|
if (c == '/') return 63;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::vector<unsigned char> base64_decode(const char *s, size_t len) {
|
||||||
|
std::vector<unsigned char> out;
|
||||||
|
out.reserve(len * 3 / 4 + 4);
|
||||||
|
int buf = 0, bits = 0;
|
||||||
|
for (size_t i = 0; i < len; i++) {
|
||||||
|
char c = s[i];
|
||||||
|
if (c == '=' || c == '\n' || c == '\r' || c == ' ' || c == '\t') continue;
|
||||||
|
int v = b64_val(c);
|
||||||
|
if (v < 0) continue;
|
||||||
|
buf = (buf << 6) | v;
|
||||||
|
bits += 6;
|
||||||
|
if (bits >= 8) {
|
||||||
|
bits -= 8;
|
||||||
|
out.push_back((unsigned char)((buf >> bits) & 0xff));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Filename / path helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
static std::string basename_only(const std::string &p) {
|
||||||
|
auto pos = p.find_last_of("\\/");
|
||||||
|
return (pos == std::string::npos) ? p : p.substr(pos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string sanitize_filename(const std::string &name) {
|
||||||
|
std::string b = basename_only(name);
|
||||||
|
std::string out;
|
||||||
|
for (char c : b) {
|
||||||
|
if (c == ':' || c == '*' || c == '?' || c == '"' ||
|
||||||
|
c == '<' || c == '>' || c == '|' || c == '\\' || c == '/') {
|
||||||
|
out += '_';
|
||||||
|
} else if ((unsigned char)c < 0x20) {
|
||||||
|
out += '_';
|
||||||
|
} else {
|
||||||
|
out += c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (out.empty()) out = "file";
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string guess_mime(const std::string &name) {
|
||||||
|
auto pos = name.find_last_of('.');
|
||||||
|
if (pos == std::string::npos) return "application/octet-stream";
|
||||||
|
std::string ext = name.substr(pos + 1);
|
||||||
|
for (auto &c : ext) c = (char)tolower((unsigned char)c);
|
||||||
|
if (ext == "png") return "image/png";
|
||||||
|
if (ext == "jpg" || ext == "jpeg") return "image/jpeg";
|
||||||
|
if (ext == "gif") return "image/gif";
|
||||||
|
if (ext == "webp") return "image/webp";
|
||||||
|
if (ext == "bmp") return "image/bmp";
|
||||||
|
if (ext == "svg") return "image/svg+xml";
|
||||||
|
if (ext == "txt") return "text/plain";
|
||||||
|
if (ext == "pdf") return "application/pdf";
|
||||||
|
if (ext == "zip") return "application/zip";
|
||||||
|
if (ext == "json") return "application/json";
|
||||||
|
return "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string get_save_dir() {
|
||||||
|
char prof[MAX_PATH];
|
||||||
|
DWORD n = GetEnvironmentVariableA("USERPROFILE", prof, MAX_PATH);
|
||||||
|
if (n == 0 || n >= MAX_PATH) return "";
|
||||||
|
std::string dir(prof);
|
||||||
|
dir += "\\Downloads";
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
static std::string unique_path(const std::string &dir, const std::string &name) {
|
||||||
|
std::string full = dir + "\\" + name;
|
||||||
|
DWORD attrs = GetFileAttributesA(full.c_str());
|
||||||
|
if (attrs == INVALID_FILE_ATTRIBUTES) return full;
|
||||||
|
|
||||||
|
auto dot = name.find_last_of('.');
|
||||||
|
std::string stem = (dot == std::string::npos) ? name : name.substr(0, dot);
|
||||||
|
std::string ext = (dot == std::string::npos) ? "" : name.substr(dot);
|
||||||
|
for (int i = 1; i < 1000; i++) {
|
||||||
|
char buf[32];
|
||||||
|
sprintf(buf, " (%d)", i);
|
||||||
|
std::string cand = dir + "\\" + stem + buf + ext;
|
||||||
|
if (GetFileAttributesA(cand.c_str()) == INVALID_FILE_ATTRIBUTES) return cand;
|
||||||
|
}
|
||||||
|
return full;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* send_event — call host callback */
|
/* send_event — call host callback */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
static void send_event(const char *event, const char *payload) {
|
static void send_event(const char *event, const char *payload, size_t payloadLen) {
|
||||||
host_callback_t cb = g_callback;
|
host_callback_t cb = g_callback;
|
||||||
if (!cb) return;
|
if (!cb) return;
|
||||||
int elen = event ? (int)strlen(event) : 0;
|
int elen = event ? (int)strlen(event) : 0;
|
||||||
int plen = payload ? (int)strlen(payload) : 0;
|
cb(event, (uintptr_t)elen, payload, (uintptr_t)payloadLen);
|
||||||
cb(event, (uintptr_t)elen, payload, (uintptr_t)plen);
|
}
|
||||||
|
|
||||||
|
static void send_event(const char *event, const char *payload) {
|
||||||
|
send_event(event, payload, payload ? strlen(payload) : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -181,6 +324,12 @@ static void append_to_log(const char *text) {
|
|||||||
SendMessageA(g_hwnd_log, EM_SCROLLCARET, 0, 0);
|
SendMessageA(g_hwnd_log, EM_SCROLLCARET, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void post_log_line(const std::string &text) {
|
||||||
|
if (!g_hwnd) return;
|
||||||
|
char *dup = _strdup(text.c_str());
|
||||||
|
PostMessageA(g_hwnd, WM_CHAT_APPEND, 0, (LPARAM)dup);
|
||||||
|
}
|
||||||
|
|
||||||
static void do_send_message() {
|
static void do_send_message() {
|
||||||
char buf[4096];
|
char buf[4096];
|
||||||
int len = GetWindowTextA(g_hwnd_input, buf, sizeof(buf));
|
int len = GetWindowTextA(g_hwnd_input, buf, sizeof(buf));
|
||||||
@@ -196,6 +345,68 @@ static void do_send_message() {
|
|||||||
send_event("chat_message", payload.c_str());
|
send_event("chat_message", payload.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void do_attach_file() {
|
||||||
|
char path[MAX_PATH * 2] = {0};
|
||||||
|
OPENFILENAMEA ofn = {};
|
||||||
|
ofn.lStructSize = sizeof(ofn);
|
||||||
|
ofn.hwndOwner = g_hwnd;
|
||||||
|
ofn.lpstrFile = path;
|
||||||
|
ofn.nMaxFile = sizeof(path);
|
||||||
|
ofn.lpstrFilter = "All Files\0*.*\0Images\0*.png;*.jpg;*.jpeg;*.gif;*.webp;*.bmp\0";
|
||||||
|
ofn.lpstrTitle = "Attach file";
|
||||||
|
ofn.Flags = OFN_PATHMUSTEXIST | OFN_FILEMUSTEXIST | OFN_EXPLORER | OFN_NOCHANGEDIR;
|
||||||
|
if (!GetOpenFileNameA(&ofn)) return;
|
||||||
|
|
||||||
|
FILE *f = fopen(path, "rb");
|
||||||
|
if (!f) {
|
||||||
|
post_log_line("[chat] Could not open selected file\r\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fseek(f, 0, SEEK_END);
|
||||||
|
long fsize = ftell(f);
|
||||||
|
fseek(f, 0, SEEK_SET);
|
||||||
|
if (fsize <= 0) { fclose(f); return; }
|
||||||
|
if ((size_t)fsize > MAX_ATTACHMENT_BYTES) {
|
||||||
|
fclose(f);
|
||||||
|
char msg[160];
|
||||||
|
sprintf(msg, "[chat] File too large (%ld bytes); max is %d bytes\r\n",
|
||||||
|
fsize, MAX_ATTACHMENT_BYTES);
|
||||||
|
post_log_line(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<unsigned char> data((size_t)fsize);
|
||||||
|
size_t got = fread(data.data(), 1, (size_t)fsize, f);
|
||||||
|
fclose(f);
|
||||||
|
if (got != (size_t)fsize) {
|
||||||
|
post_log_line("[chat] Failed to read attachment\r\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string name = basename_only(path);
|
||||||
|
std::string mime = guess_mime(name);
|
||||||
|
std::string b64 = base64_encode(data.data(), data.size());
|
||||||
|
|
||||||
|
std::string payload;
|
||||||
|
payload.reserve(b64.size() + 256);
|
||||||
|
payload += "{\"from\":\"";
|
||||||
|
payload += json_escape(g_config.targetName);
|
||||||
|
payload += "\",\"name\":\"";
|
||||||
|
payload += json_escape(name);
|
||||||
|
payload += "\",\"mime\":\"";
|
||||||
|
payload += json_escape(mime);
|
||||||
|
payload += "\",\"dataB64\":\"";
|
||||||
|
payload += b64;
|
||||||
|
payload += "\"}";
|
||||||
|
|
||||||
|
char fmt[256];
|
||||||
|
sprintf(fmt, "%s sent file: %s (%ld bytes)\r\n",
|
||||||
|
g_config.targetName, name.c_str(), fsize);
|
||||||
|
post_log_line(fmt);
|
||||||
|
|
||||||
|
send_event("chat_attachment", payload.data(), payload.size());
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Input subclass — Enter key sends message */
|
/* Input subclass — Enter key sends message */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -230,6 +441,11 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
|
|
||||||
SetWindowTheme(g_hwnd_log, L"DarkMode_Explorer", NULL);
|
SetWindowTheme(g_hwnd_log, L"DarkMode_Explorer", NULL);
|
||||||
|
|
||||||
|
g_hwnd_attach = CreateWindowExA(
|
||||||
|
0, "BUTTON", "+",
|
||||||
|
WS_CHILD | WS_VISIBLE | BS_OWNERDRAW | WS_TABSTOP,
|
||||||
|
0, 0, 0, 0, hwnd, (HMENU)(uintptr_t)IDC_ATTACH, g_hInstance, NULL);
|
||||||
|
|
||||||
g_hwnd_input = CreateWindowExA(
|
g_hwnd_input = CreateWindowExA(
|
||||||
0, "EDIT", "",
|
0, "EDIT", "",
|
||||||
WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL | WS_TABSTOP,
|
WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL | WS_TABSTOP,
|
||||||
@@ -242,9 +458,10 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
WS_CHILD | WS_VISIBLE | BS_OWNERDRAW | WS_TABSTOP,
|
WS_CHILD | WS_VISIBLE | BS_OWNERDRAW | WS_TABSTOP,
|
||||||
0, 0, 0, 0, hwnd, (HMENU)(uintptr_t)IDC_SEND, g_hInstance, NULL);
|
0, 0, 0, 0, hwnd, (HMENU)(uintptr_t)IDC_SEND, g_hInstance, NULL);
|
||||||
|
|
||||||
SendMessageA(g_hwnd_log, WM_SETFONT, (WPARAM)g_font, TRUE);
|
SendMessageA(g_hwnd_log, WM_SETFONT, (WPARAM)g_font, TRUE);
|
||||||
SendMessageA(g_hwnd_input, WM_SETFONT, (WPARAM)g_font, TRUE);
|
SendMessageA(g_hwnd_input, WM_SETFONT, (WPARAM)g_font, TRUE);
|
||||||
SendMessageA(g_hwnd_send, WM_SETFONT, (WPARAM)g_font, TRUE);
|
SendMessageA(g_hwnd_send, WM_SETFONT, (WPARAM)g_font, TRUE);
|
||||||
|
SendMessageA(g_hwnd_attach, WM_SETFONT, (WPARAM)g_font, TRUE);
|
||||||
|
|
||||||
g_orig_input_proc = (WNDPROC)SetWindowLongPtrA(
|
g_orig_input_proc = (WNDPROC)SetWindowLongPtrA(
|
||||||
g_hwnd_input, GWLP_WNDPROC, (LONG_PTR)InputSubclassProc);
|
g_hwnd_input, GWLP_WNDPROC, (LONG_PTR)InputSubclassProc);
|
||||||
@@ -261,11 +478,16 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
RECT rc;
|
RECT rc;
|
||||||
GetClientRect(hwnd, &rc);
|
GetClientRect(hwnd, &rc);
|
||||||
int w = rc.right, h = rc.bottom;
|
int w = rc.right, h = rc.bottom;
|
||||||
int pad = 10, inputH = 32, btnW = 65, gap = 8;
|
int pad = 10, inputH = 32, btnW = 65, attachW = 32, gap = 8;
|
||||||
|
|
||||||
MoveWindow(g_hwnd_log, pad, pad, w - 2 * pad, h - inputH - 3 * pad, TRUE);
|
MoveWindow(g_hwnd_log, pad, pad, w - 2 * pad, h - inputH - 3 * pad, TRUE);
|
||||||
MoveWindow(g_hwnd_input, pad, h - inputH - pad, w - btnW - 2 * pad - gap, inputH, TRUE);
|
MoveWindow(g_hwnd_attach, pad, h - inputH - pad, attachW, inputH, TRUE);
|
||||||
MoveWindow(g_hwnd_send, w - btnW - pad, h - inputH - pad, btnW, inputH, TRUE);
|
MoveWindow(g_hwnd_input,
|
||||||
|
pad + attachW + gap,
|
||||||
|
h - inputH - pad,
|
||||||
|
w - btnW - attachW - 2 * pad - 2 * gap,
|
||||||
|
inputH, TRUE);
|
||||||
|
MoveWindow(g_hwnd_send, w - btnW - pad, h - inputH - pad, btnW, inputH, TRUE);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,6 +504,11 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
SetFocus(g_hwnd_input);
|
SetFocus(g_hwnd_input);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (LOWORD(wp) == IDC_ATTACH && HIWORD(wp) == BN_CLICKED) {
|
||||||
|
do_attach_file();
|
||||||
|
SetFocus(g_hwnd_input);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case WM_CTLCOLOREDIT: {
|
case WM_CTLCOLOREDIT: {
|
||||||
@@ -337,6 +564,30 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||||
return TRUE;
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
if (dis->CtlID == IDC_ATTACH) {
|
||||||
|
COLORREF col = (dis->itemState & ODS_SELECTED)
|
||||||
|
? RGB(48, 56, 72)
|
||||||
|
: RGB(60, 68, 86);
|
||||||
|
HBRUSH br = CreateSolidBrush(col);
|
||||||
|
FillRect(dis->hDC, &dis->rcItem, br);
|
||||||
|
DeleteObject(br);
|
||||||
|
|
||||||
|
HPEN pen = CreatePen(PS_SOLID, 1, RGB(80, 90, 110));
|
||||||
|
HPEN oldPen = (HPEN)SelectObject(dis->hDC, pen);
|
||||||
|
HBRUSH oldBr = (HBRUSH)SelectObject(dis->hDC, GetStockObject(NULL_BRUSH));
|
||||||
|
RoundRect(dis->hDC, dis->rcItem.left, dis->rcItem.top,
|
||||||
|
dis->rcItem.right, dis->rcItem.bottom, 6, 6);
|
||||||
|
SelectObject(dis->hDC, oldPen);
|
||||||
|
SelectObject(dis->hDC, oldBr);
|
||||||
|
DeleteObject(pen);
|
||||||
|
|
||||||
|
SetTextColor(dis->hDC, RGB(220, 225, 234));
|
||||||
|
SetBkMode(dis->hDC, TRANSPARENT);
|
||||||
|
SelectObject(dis->hDC, g_font);
|
||||||
|
DrawTextA(dis->hDC, "+", -1, &dis->rcItem,
|
||||||
|
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,10 +615,11 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||||||
SetWindowLongPtrA(g_hwnd_input, GWLP_WNDPROC, (LONG_PTR)g_orig_input_proc);
|
SetWindowLongPtrA(g_hwnd_input, GWLP_WNDPROC, (LONG_PTR)g_orig_input_proc);
|
||||||
g_orig_input_proc = NULL;
|
g_orig_input_proc = NULL;
|
||||||
}
|
}
|
||||||
g_hwnd_log = NULL;
|
g_hwnd_log = NULL;
|
||||||
g_hwnd_input = NULL;
|
g_hwnd_input = NULL;
|
||||||
g_hwnd_send = NULL;
|
g_hwnd_send = NULL;
|
||||||
g_hwnd = NULL;
|
g_hwnd_attach = NULL;
|
||||||
|
g_hwnd = NULL;
|
||||||
if (g_font) { DeleteObject(g_font); g_font = NULL; }
|
if (g_font) { DeleteObject(g_font); g_font = NULL; }
|
||||||
if (g_bg_brush) { DeleteObject(g_bg_brush); g_bg_brush = NULL; }
|
if (g_bg_brush) { DeleteObject(g_bg_brush); g_bg_brush = NULL; }
|
||||||
if (g_log_brush) { DeleteObject(g_log_brush); g_log_brush = NULL; }
|
if (g_log_brush) { DeleteObject(g_log_brush); g_log_brush = NULL; }
|
||||||
@@ -501,7 +753,7 @@ EXPORT int PluginOnEvent(const char *event, int eventLen,
|
|||||||
std::string tgName = json_extract(pl.c_str(), (int)pl.size(), "targetName");
|
std::string tgName = json_extract(pl.c_str(), (int)pl.size(), "targetName");
|
||||||
std::string title = json_extract(pl.c_str(), (int)pl.size(), "title");
|
std::string title = json_extract(pl.c_str(), (int)pl.size(), "title");
|
||||||
bool closable = json_extract_bool(pl.c_str(), (int)pl.size(), "closable", true);
|
bool closable = json_extract_bool(pl.c_str(), (int)pl.size(), "closable", true);
|
||||||
bool onTop = json_extract_bool(pl.c_str(), (int)pl.size(), "alwaysOnTop", false);
|
bool onTop = json_extract_bool(pl.c_str(), (int)pl.size(), "alwaysOnTop", false);
|
||||||
|
|
||||||
EnterCriticalSection(&g_cs);
|
EnterCriticalSection(&g_cs);
|
||||||
if (!opName.empty()) strncpy(g_config.operatorName, opName.c_str(), sizeof(g_config.operatorName) - 1);
|
if (!opName.empty()) strncpy(g_config.operatorName, opName.c_str(), sizeof(g_config.operatorName) - 1);
|
||||||
@@ -528,6 +780,55 @@ EXPORT int PluginOnEvent(const char *event, int eventLen,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ev == "chat_attachment") {
|
||||||
|
std::string from = json_extract(pl.c_str(), (int)pl.size(), "from");
|
||||||
|
std::string name = json_extract(pl.c_str(), (int)pl.size(), "name");
|
||||||
|
std::string b64 = json_extract(pl.c_str(), (int)pl.size(), "dataB64");
|
||||||
|
if (from.empty()) from = g_config.operatorName;
|
||||||
|
if (name.empty()) name = "file";
|
||||||
|
if (b64.empty()) {
|
||||||
|
std::string err = "[chat] attachment from " + from + " had no data\r\n";
|
||||||
|
post_log_line(err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<unsigned char> bytes = base64_decode(b64.data(), b64.size());
|
||||||
|
if (bytes.empty()) {
|
||||||
|
std::string err = "[chat] failed to decode attachment from " + from + "\r\n";
|
||||||
|
post_log_line(err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string dir = get_save_dir();
|
||||||
|
if (dir.empty()) {
|
||||||
|
post_log_line("[chat] could not resolve temp directory\r\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
std::string safe = sanitize_filename(name);
|
||||||
|
std::string full = unique_path(dir, safe);
|
||||||
|
|
||||||
|
FILE *f = fopen(full.c_str(), "wb");
|
||||||
|
if (!f) {
|
||||||
|
std::string err = "[chat] could not write " + full + "\r\n";
|
||||||
|
post_log_line(err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
size_t wrote = fwrite(bytes.data(), 1, bytes.size(), f);
|
||||||
|
fclose(f);
|
||||||
|
if (wrote != bytes.size()) {
|
||||||
|
std::string err = "[chat] short write to " + full + "\r\n";
|
||||||
|
post_log_line(err);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
char header[128];
|
||||||
|
sprintf(header, "%s sent file: %s (%zu bytes)\r\n",
|
||||||
|
from.c_str(), name.c_str(), bytes.size());
|
||||||
|
std::string line = std::string(header) + " Saved to: " + full + "\r\n";
|
||||||
|
post_log_line(line);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
if (ev == "close_chat") {
|
if (ev == "close_chat") {
|
||||||
if (g_hwnd) send_event("chat_closed", "{}");
|
if (g_hwnd) send_event("chat_closed", "{}");
|
||||||
close_chat_window();
|
close_chat_window();
|
||||||
|
|||||||
+137
-5
@@ -15,12 +15,32 @@ export default {
|
|||||||
`CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)`
|
`CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)`
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
|
const cols = ctx.db
|
||||||
|
.prepare(`PRAGMA table_info(messages)`)
|
||||||
|
.all()
|
||||||
|
.map((r) => r.name);
|
||||||
|
if (!cols.includes("attachment_name")) {
|
||||||
|
try {
|
||||||
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_name TEXT`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!cols.includes("attachment_mime")) {
|
||||||
|
try {
|
||||||
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_mime TEXT`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (!cols.includes("attachment_data")) {
|
||||||
|
try {
|
||||||
|
ctx.db.exec(`ALTER TABLE messages ADD COLUMN attachment_data BLOB`);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onEvent(ctx, clientId, event, payload) {
|
onEvent(ctx, clientId, event, payload) {
|
||||||
if (event === "chat_message") {
|
if (event === "chat_message") {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
ctx.db
|
const info = ctx.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
@@ -32,6 +52,7 @@ export default {
|
|||||||
ts
|
ts
|
||||||
);
|
);
|
||||||
ctx.broadcast("new_message", {
|
ctx.broadcast("new_message", {
|
||||||
|
id: Number(info.lastInsertRowid),
|
||||||
clientId,
|
clientId,
|
||||||
sender: payload.from || "Unknown",
|
sender: payload.from || "Unknown",
|
||||||
direction: "from_target",
|
direction: "from_target",
|
||||||
@@ -39,6 +60,41 @@ export default {
|
|||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (event === "chat_attachment") {
|
||||||
|
const ts = Date.now();
|
||||||
|
const name = payload.name || "file";
|
||||||
|
const mime = payload.mime || "application/octet-stream";
|
||||||
|
let buf = null;
|
||||||
|
try {
|
||||||
|
buf = Buffer.from(payload.dataB64 || "", "base64");
|
||||||
|
} catch (_) {
|
||||||
|
buf = null;
|
||||||
|
}
|
||||||
|
if (!buf || buf.length === 0) return;
|
||||||
|
const info = ctx.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
clientId,
|
||||||
|
payload.from || "Unknown",
|
||||||
|
"from_target",
|
||||||
|
"",
|
||||||
|
ts,
|
||||||
|
name,
|
||||||
|
mime,
|
||||||
|
buf
|
||||||
|
);
|
||||||
|
ctx.broadcast("new_message", {
|
||||||
|
id: Number(info.lastInsertRowid),
|
||||||
|
clientId,
|
||||||
|
sender: payload.from || "Unknown",
|
||||||
|
direction: "from_target",
|
||||||
|
text: "",
|
||||||
|
timestamp: ts,
|
||||||
|
attachment: { name, mime, size: buf.length },
|
||||||
|
});
|
||||||
|
}
|
||||||
if (event === "chat_opened") {
|
if (event === "chat_opened") {
|
||||||
ctx.broadcast("chat_status", { clientId, status: "opened" });
|
ctx.broadcast("chat_status", { clientId, status: "opened" });
|
||||||
}
|
}
|
||||||
@@ -51,26 +107,102 @@ export default {
|
|||||||
get_history(ctx, params) {
|
get_history(ctx, params) {
|
||||||
return ctx.db
|
return ctx.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT * FROM messages WHERE client_id = ? ORDER BY timestamp ASC LIMIT 500`
|
`SELECT id, client_id, sender, direction, text, timestamp,
|
||||||
|
attachment_name, attachment_mime,
|
||||||
|
CASE WHEN attachment_data IS NULL THEN 0 ELSE length(attachment_data) END AS attachment_size
|
||||||
|
FROM messages WHERE client_id = ? ORDER BY timestamp ASC LIMIT 500`
|
||||||
)
|
)
|
||||||
.all(params.clientId);
|
.all(params.clientId)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
clientId: r.client_id,
|
||||||
|
sender: r.sender,
|
||||||
|
direction: r.direction,
|
||||||
|
text: r.text,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
attachment: r.attachment_name
|
||||||
|
? {
|
||||||
|
name: r.attachment_name,
|
||||||
|
mime: r.attachment_mime,
|
||||||
|
size: r.attachment_size,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
get_attachment(ctx, params) {
|
||||||
|
const row = ctx.db
|
||||||
|
.prepare(
|
||||||
|
`SELECT attachment_name, attachment_mime, attachment_data FROM messages WHERE id = ?`
|
||||||
|
)
|
||||||
|
.get(params.id);
|
||||||
|
if (!row || !row.attachment_data) {
|
||||||
|
return { ok: false, error: "not found" };
|
||||||
|
}
|
||||||
|
const buf = Buffer.isBuffer(row.attachment_data)
|
||||||
|
? row.attachment_data
|
||||||
|
: Buffer.from(row.attachment_data);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
name: row.attachment_name,
|
||||||
|
mime: row.attachment_mime,
|
||||||
|
dataB64: buf.toString("base64"),
|
||||||
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
store_message(ctx, params) {
|
store_message(ctx, params) {
|
||||||
const ts = Date.now();
|
const ts = Date.now();
|
||||||
ctx.db
|
const info = ctx.db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
`INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(params.clientId, params.sender, "to_target", params.text, ts);
|
.run(params.clientId, params.sender, "to_target", params.text, ts);
|
||||||
ctx.broadcast("new_message", {
|
ctx.broadcast("new_message", {
|
||||||
|
id: Number(info.lastInsertRowid),
|
||||||
clientId: params.clientId,
|
clientId: params.clientId,
|
||||||
sender: params.sender,
|
sender: params.sender,
|
||||||
direction: "to_target",
|
direction: "to_target",
|
||||||
text: params.text,
|
text: params.text,
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
});
|
});
|
||||||
return { ok: true, timestamp: ts };
|
return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
|
||||||
|
},
|
||||||
|
|
||||||
|
store_attachment(ctx, params) {
|
||||||
|
const ts = Date.now();
|
||||||
|
const name = params.name || "file";
|
||||||
|
const mime = params.mime || "application/octet-stream";
|
||||||
|
let buf = null;
|
||||||
|
try {
|
||||||
|
buf = Buffer.from(params.dataB64 || "", "base64");
|
||||||
|
} catch (_) {}
|
||||||
|
if (!buf || buf.length === 0) {
|
||||||
|
return { ok: false, error: "empty data" };
|
||||||
|
}
|
||||||
|
const info = ctx.db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO messages(client_id, sender, direction, text, timestamp, attachment_name, attachment_mime, attachment_data) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
params.clientId,
|
||||||
|
params.sender,
|
||||||
|
"to_target",
|
||||||
|
"",
|
||||||
|
ts,
|
||||||
|
name,
|
||||||
|
mime,
|
||||||
|
buf
|
||||||
|
);
|
||||||
|
ctx.broadcast("new_message", {
|
||||||
|
id: Number(info.lastInsertRowid),
|
||||||
|
clientId: params.clientId,
|
||||||
|
sender: params.sender,
|
||||||
|
direction: "to_target",
|
||||||
|
text: "",
|
||||||
|
timestamp: ts,
|
||||||
|
attachment: { name, mime, size: buf.length },
|
||||||
|
});
|
||||||
|
return { ok: true, id: Number(info.lastInsertRowid), timestamp: ts };
|
||||||
},
|
},
|
||||||
|
|
||||||
clear_history(ctx, params) {
|
clear_history(ctx, params) {
|
||||||
|
|||||||
Reference in New Issue
Block a user