chat plugin (fully AI I'm not writing ts)

This commit is contained in:
KingKDot
2026-05-13 10:11:22 -05:00
parent 637f7b5a4f
commit 30b6c63cfc
7 changed files with 1286 additions and 0 deletions
+101
View File
@@ -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%
+269
View File
@@ -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;
}
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chat</title>
<link rel="stylesheet" href="/plugins/chat/assets/chat.css" />
</head>
<body>
<main class="chat-wrap">
<header class="chat-header">
<h1><i class="fa-solid fa-comments"></i> Chat</h1>
<p class="chat-sub">Two-way chat with target user (Windows only)</p>
</header>
<!-- Config Panel -->
<section class="chat-card chat-config" id="config-panel">
<div class="chat-config-toggle" id="config-toggle">
<span>Configuration</span>
<i class="fa-solid fa-chevron-down" id="config-chevron"></i>
</div>
<div class="chat-config-body" id="config-body">
<div class="chat-grid">
<label>Client ID</label>
<input id="client-id" type="text" class="chat-field" placeholder="paste client ID" />
<label>Your Name</label>
<input id="operator-name" type="text" class="chat-field" value="Operator" />
<label>Target Name</label>
<input id="target-name" type="text" class="chat-field" value="User" />
<label>Window Title</label>
<input id="window-title" type="text" class="chat-field" value="Chat" />
<label>Options</label>
<div class="chat-checkboxes">
<label><input type="checkbox" id="opt-closable" checked /> Closable</label>
<label><input type="checkbox" id="opt-ontop" /> Always on Top</label>
</div>
</div>
<div class="chat-config-actions">
<button id="btn-open" class="chat-btn chat-btn-blue">Open Chat</button>
<button id="btn-close" class="chat-btn chat-btn-red" disabled>Close Chat</button>
<button id="btn-clear" class="chat-btn chat-btn-muted">Clear History</button>
</div>
</div>
</section>
<!-- Status -->
<div class="chat-status" id="status">
<span class="chat-status-dot" id="status-dot"></span>
<span id="status-text">Chat not opened</span>
</div>
<!-- Messages -->
<section class="chat-card chat-messages" id="messages"></section>
<!-- Input -->
<section class="chat-input-area">
<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>
</section>
</main>
<script src="/plugins/chat/assets/chat.js"></script>
</body>
</html>
+208
View File
@@ -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();
}
})();
+554
View File
@@ -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 <cstdint>
#include <cstring>
#include <cstdio>
#include <cstdlib>
#include <string>
#define EXPORT extern "C" __declspec(dllexport)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <uxtheme.h>
#include <dwmapi.h>
#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<host_callback_t>(static_cast<uintptr_t>(cb));
}
EXPORT int PluginOnLoad(const char *hostInfo, int hostInfoLen, uint64_t cb) {
g_callback = reinterpret_cast<host_callback_t>(static_cast<uintptr_t>(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;
}
}
+84
View File
@@ -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 };
},
},
};