Initial commit: POE2 automated trade bot

Monitors pathofexile.com/trade2 for new listings, travels to seller
hideouts, buys items from public stash tabs, and stores them.

Includes persistent C# OCR daemon for fast screen capture + Windows
native OCR, web dashboard for managing trade links and settings,
and full game automation via Win32 SendInput.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Boki 2026-02-10 14:03:47 -05:00
commit 41d174195e
28 changed files with 6449 additions and 0 deletions

670
src/dashboard/index.html Normal file
View file

@ -0,0 +1,670 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>POE2 Trade Bot</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', system-ui, sans-serif;
background: #0d1117;
color: #e6edf3;
min-height: 100vh;
}
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #30363d;
margin-bottom: 20px;
}
header h1 { font-size: 20px; font-weight: 600; }
.header-right { display: flex; align-items: center; gap: 14px; }
.settings-btn {
background: none; border: none; cursor: pointer; padding: 4px;
color: #8b949e; transition: color 0.15s; line-height: 1;
}
.settings-btn:hover { background: none; color: #e6edf3; }
.settings-btn svg { display: block; }
.status-dot {
width: 10px; height: 10px; border-radius: 50%;
display: inline-block; margin-right: 8px;
}
.status-dot.running { background: #3fb950; box-shadow: 0 0 6px #3fb950; }
.status-dot.paused { background: #d29922; box-shadow: 0 0 6px #d29922; }
.status-dot.idle { background: #8b949e; }
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 14px;
text-align: center;
}
.stat-card .value { font-size: 24px; font-weight: 700; color: #58a6ff; }
.stat-card .label { font-size: 11px; color: #8b949e; text-transform: uppercase; margin-top: 4px; }
.controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
}
button {
padding: 8px 20px;
border: 1px solid #30363d;
border-radius: 6px;
background: #21262d;
color: #e6edf3;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background 0.15s;
}
button:hover { background: #30363d; }
button.primary { background: #238636; border-color: #2ea043; }
button.primary:hover { background: #2ea043; }
button.danger { background: #da3633; border-color: #f85149; }
button.danger:hover { background: #f85149; }
button.warning { background: #9e6a03; border-color: #d29922; }
button.warning:hover { background: #d29922; }
.section { margin-bottom: 20px; }
.section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
color: #8b949e;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.add-link {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.add-link input {
flex: 1;
padding: 8px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 13px;
outline: none;
}
.add-link input:focus { border-color: #58a6ff; }
.add-link input::placeholder { color: #484f58; }
.links-list { display: flex; flex-direction: column; gap: 6px; }
.link-item {
display: flex;
align-items: center;
justify-content: space-between;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 10px 14px;
}
.link-left { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.link-info { flex: 1; min-width: 0; }
.link-name { font-size: 13px; font-weight: 600; color: #e6edf3; }
.link-label { font-size: 12px; color: #8b949e; }
.link-url {
font-size: 11px; color: #484f58;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 600px;
}
.link-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.link-item button { padding: 4px 12px; font-size: 12px; }
.link-item.inactive { opacity: 0.5; }
/* Toggle switch */
.toggle { position: relative; width: 36px; height: 20px; cursor: pointer; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.toggle .slider {
position: absolute; inset: 0; background: #30363d;
border-radius: 20px; transition: background 0.2s;
}
.toggle .slider::before {
content: ''; position: absolute; left: 2px; top: 2px;
width: 16px; height: 16px; background: #8b949e;
border-radius: 50%; transition: transform 0.2s, background 0.2s;
}
.toggle input:checked + .slider { background: #238636; }
.toggle input:checked + .slider::before { transform: translateX(16px); background: #fff; }
.log-panel {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
height: 280px;
overflow-y: auto;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
padding: 10px;
}
.log-line { padding: 2px 0; line-height: 1.5; }
.log-line .time { color: #484f58; }
.log-line.info .msg { color: #58a6ff; }
.log-line.warn .msg { color: #d29922; }
.log-line.error .msg { color: #f85149; }
.log-line.debug .msg { color: #8b949e; }
.empty-state {
color: #484f58;
text-align: center;
padding: 30px;
font-size: 13px;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.settings-grid.full { grid-template-columns: 1fr; }
.setting-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-row label {
font-size: 11px;
color: #8b949e;
text-transform: uppercase;
}
.setting-row input {
padding: 6px 10px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 13px;
outline: none;
}
.setting-row input:focus { border-color: #58a6ff; }
.settings-actions {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.saved-badge {
font-size: 12px;
color: #3fb950;
margin-right: 12px;
line-height: 32px;
opacity: 0;
transition: opacity 0.3s;
}
.saved-badge.show { opacity: 1; }
/* Modal overlay */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 100;
justify-content: center;
align-items: center;
}
.modal-overlay.open { display: flex; }
.modal {
background: #161b22;
border: 1px solid #30363d;
border-radius: 10px;
width: 480px;
max-width: 90vw;
max-height: 85vh;
overflow-y: auto;
padding: 24px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.modal-header h2 { font-size: 16px; font-weight: 600; }
.modal-close {
background: none; border: none; color: #8b949e;
cursor: pointer; padding: 4px; font-size: 18px; line-height: 1;
}
.modal-close:hover { background: none; color: #e6edf3; }
.debug-panel {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 14px;
}
.debug-row {
display: flex;
gap: 8px;
margin-bottom: 8px;
align-items: center;
}
.debug-row:last-of-type { margin-bottom: 0; }
.debug-row input {
padding: 6px 10px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 13px;
outline: none;
}
.debug-row input:focus { border-color: #58a6ff; }
.debug-row input[type="text"] { flex: 1; }
.debug-result {
margin-top: 8px;
font-family: 'Cascadia Code', 'Fira Code', monospace;
font-size: 12px;
color: #8b949e;
white-space: pre-wrap;
max-height: 200px;
overflow-y: auto;
}
.debug-result:empty { display: none; }
</style>
</head>
<body>
<div class="container">
<header>
<h1>POE2 Trade Bot</h1>
<div class="header-right">
<div id="statusBadge">
<span class="status-dot idle"></span>
<span id="statusText">Connecting...</span>
</div>
<button class="settings-btn" onclick="openSettings()" title="Settings">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<circle cx="10" cy="10" r="3"/>
<path d="M10 1.5v2M10 16.5v2M3.4 3.4l1.4 1.4M15.2 15.2l1.4 1.4M1.5 10h2M16.5 10h2M3.4 16.6l1.4-1.4M15.2 4.8l1.4-1.4"/>
</svg>
</button>
</div>
</header>
<div class="stats">
<div class="stat-card">
<div class="value" id="stateValue">IDLE</div>
<div class="label">State</div>
</div>
<div class="stat-card">
<div class="value" id="linksValue">0</div>
<div class="label">Active Links</div>
</div>
<div class="stat-card">
<div class="value" id="completedValue">0</div>
<div class="label">Trades Done</div>
</div>
<div class="stat-card">
<div class="value" id="failedValue">0</div>
<div class="label">Failed</div>
</div>
</div>
<div class="controls" id="controls">
<button class="warning" id="pauseBtn" onclick="togglePause()">Pause</button>
</div>
<div class="section">
<div class="section-title">Trade Links</div>
<div class="add-link">
<input type="text" id="nameInput" placeholder="Name (optional)" style="max-width:180px" />
<input type="text" id="urlInput" placeholder="Paste trade URL..." />
<button class="primary" onclick="addLink()">Add</button>
</div>
<div class="links-list" id="linksList">
<div class="empty-state">No trade links added yet</div>
</div>
</div>
<div class="section">
<div class="section-title">Debug Tools</div>
<div class="debug-panel">
<div class="debug-row">
<button onclick="debugScreenshot()">Screenshot</button>
<button onclick="debugOcr()">OCR Screen</button>
</div>
<div class="debug-row">
<input type="text" id="debugTextInput" placeholder="Text to find (e.g. Stash, Ange)" />
<button onclick="debugFindText()">Find</button>
<button class="primary" onclick="debugFindAndClick()">Find & Click</button>
</div>
<div class="debug-row">
<input type="number" id="debugClickX" placeholder="X" style="width:80px" />
<input type="number" id="debugClickY" placeholder="Y" style="width:80px" />
<button onclick="debugClick()">Click At</button>
</div>
<div class="debug-result" id="debugResult"></div>
</div>
</div>
<div class="section">
<div class="section-title">Activity Log</div>
<div class="log-panel" id="logPanel"></div>
</div>
</div>
<div class="modal-overlay" id="settingsModal">
<div class="modal">
<div class="modal-header">
<h2>Settings</h2>
<button class="modal-close" onclick="closeSettings()">&times;</button>
</div>
<div class="settings-grid full">
<div class="setting-row">
<label>POE2 Client.txt Path</label>
<input type="text" id="settLogPath" />
</div>
</div>
<div class="settings-grid" style="margin-top:10px">
<div class="setting-row">
<label>Window Title</label>
<input type="text" id="settWindowTitle" />
</div>
<div class="setting-row">
<label>Travel Timeout (ms)</label>
<input type="number" id="settTravelTimeout" />
</div>
<div class="setting-row">
<label>Wait for More Items (ms)</label>
<input type="number" id="settWaitMore" />
</div>
<div class="setting-row">
<label>Delay Between Trades (ms)</label>
<input type="number" id="settTradeDelay" />
</div>
</div>
<div class="settings-actions">
<span class="saved-badge" id="savedBadge">Saved</span>
<button class="primary" onclick="saveSettings()">Save Settings</button>
</div>
</div>
</div>
<script>
let ws;
let status = { paused: false, state: 'IDLE', links: [], tradesCompleted: 0, tradesFailed: 0, uptime: 0, settings: {} };
let settingsLoaded = false;
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${proto}//${location.host}`);
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'status') {
status = msg.data;
render();
} else if (msg.type === 'log') {
addLog(msg.data);
}
};
ws.onclose = () => {
addLog({ level: 'warn', message: 'Dashboard disconnected. Reconnecting...', time: new Date().toISOString() });
setTimeout(connect, 2000);
};
ws.onerror = () => {};
}
function render() {
// Status badge
const dot = document.querySelector('.status-dot');
const text = document.getElementById('statusText');
dot.className = 'status-dot ' + (status.paused ? 'paused' : status.state === 'IDLE' ? 'idle' : 'running');
text.textContent = status.paused ? 'Paused' : status.state;
// Stats
document.getElementById('stateValue').textContent = status.state;
document.getElementById('linksValue').textContent = status.links.length;
document.getElementById('completedValue').textContent = status.tradesCompleted;
document.getElementById('failedValue').textContent = status.tradesFailed;
// Pause button
const btn = document.getElementById('pauseBtn');
btn.textContent = status.paused ? 'Resume' : 'Pause';
btn.className = status.paused ? 'primary' : 'warning';
// Settings (populate once on first status)
if (status.settings) populateSettings(status.settings);
// Active links count
document.getElementById('linksValue').textContent = status.links.filter(l => l.active).length;
// Links list
const list = document.getElementById('linksList');
if (status.links.length === 0) {
list.innerHTML = '<div class="empty-state">No trade links added yet</div>';
} else {
list.innerHTML = status.links.map(link => `
<div class="link-item${link.active ? '' : ' inactive'}">
<div class="link-left">
<label class="toggle" title="${link.active ? 'Active' : 'Inactive'}">
<input type="checkbox" ${link.active ? 'checked' : ''} onchange="toggleLink('${esc(link.id)}', this.checked)" />
<span class="slider"></span>
</label>
<div class="link-info">
<div class="link-name" contenteditable="true" spellcheck="false"
onblur="renameLink('${esc(link.id)}', this.textContent)"
title="Click to edit name">${esc(link.name || link.label)}</div>
<div class="link-url">${esc(link.url)}</div>
</div>
</div>
<div class="link-actions">
<button class="danger" onclick="removeLink('${esc(link.id)}')">Remove</button>
</div>
</div>
`).join('');
}
}
function addLog(data) {
const panel = document.getElementById('logPanel');
const line = document.createElement('div');
line.className = 'log-line ' + (data.level || 'info');
const t = new Date(data.time).toLocaleTimeString();
line.innerHTML = `<span class="time">${t}</span> <span class="msg">${esc(data.message)}</span>`;
panel.appendChild(line);
if (panel.children.length > 500) panel.removeChild(panel.firstChild);
panel.scrollTop = panel.scrollHeight;
}
async function togglePause() {
const endpoint = status.paused ? '/api/resume' : '/api/pause';
await fetch(endpoint, { method: 'POST' });
}
async function addLink() {
const urlEl = document.getElementById('urlInput');
const nameEl = document.getElementById('nameInput');
const url = urlEl.value.trim();
if (!url) return;
await fetch('/api/links', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, name: nameEl.value.trim() }),
});
urlEl.value = '';
nameEl.value = '';
}
async function removeLink(id) {
await fetch('/api/links/' + encodeURIComponent(id), { method: 'DELETE' });
}
async function toggleLink(id, active) {
await fetch('/api/links/' + encodeURIComponent(id) + '/toggle', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active }),
});
}
let renameTimer = null;
async function renameLink(id, name) {
clearTimeout(renameTimer);
renameTimer = setTimeout(async () => {
await fetch('/api/links/' + encodeURIComponent(id) + '/name', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
});
}, 300);
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function populateSettings(s) {
if (!s || settingsLoaded) return;
settingsLoaded = true;
document.getElementById('settLogPath').value = s.poe2LogPath || '';
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
}
function openSettings() {
// Re-populate from latest status in case it changed
if (status.settings) {
const s = status.settings;
document.getElementById('settLogPath').value = s.poe2LogPath || '';
document.getElementById('settWindowTitle').value = s.poe2WindowTitle || '';
document.getElementById('settTravelTimeout').value = s.travelTimeoutMs || 15000;
document.getElementById('settWaitMore').value = s.waitForMoreItemsMs || 20000;
document.getElementById('settTradeDelay').value = s.betweenTradesDelayMs || 5000;
}
document.getElementById('settingsModal').classList.add('open');
}
function closeSettings() {
document.getElementById('settingsModal').classList.remove('open');
}
// Close modal on overlay click
document.getElementById('settingsModal').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeSettings();
});
// Close modal on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeSettings();
});
async function saveSettings() {
const body = {
poe2LogPath: document.getElementById('settLogPath').value,
poe2WindowTitle: document.getElementById('settWindowTitle').value,
travelTimeoutMs: parseInt(document.getElementById('settTravelTimeout').value) || 15000,
waitForMoreItemsMs: parseInt(document.getElementById('settWaitMore').value) || 20000,
betweenTradesDelayMs: parseInt(document.getElementById('settTradeDelay').value) || 5000,
};
await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const badge = document.getElementById('savedBadge');
badge.classList.add('show');
setTimeout(() => badge.classList.remove('show'), 2000);
}
// Debug functions
async function debugScreenshot() {
const res = await fetch('/api/debug/screenshot', { method: 'POST' });
const data = await res.json();
showDebugResult(data.ok ? `Screenshot saved: ${data.filename}` : `Error: ${data.error}`);
}
async function debugOcr() {
showDebugResult('Running OCR...');
const res = await fetch('/api/debug/ocr', { method: 'POST' });
const data = await res.json();
showDebugResult(data.ok ? data.text : `Error: ${data.error}`);
}
async function debugFindText() {
const text = document.getElementById('debugTextInput').value.trim();
if (!text) return;
showDebugResult(`Searching for "${text}"...`);
const res = await fetch('/api/debug/find-text', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
const data = await res.json();
if (data.found) {
showDebugResult(`Found "${text}" at (${data.position.x}, ${data.position.y})`);
document.getElementById('debugClickX').value = data.position.x;
document.getElementById('debugClickY').value = data.position.y;
} else {
showDebugResult(`"${text}" not found on screen`);
}
}
async function debugFindAndClick() {
const text = document.getElementById('debugTextInput').value.trim();
if (!text) return;
showDebugResult(`Finding and clicking "${text}"...`);
const res = await fetch('/api/debug/find-and-click', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
});
const data = await res.json();
if (data.found) {
showDebugResult(`Clicked "${text}" at (${data.position.x}, ${data.position.y})`);
} else {
showDebugResult(`"${text}" not found on screen`);
}
}
async function debugClick() {
const x = parseInt(document.getElementById('debugClickX').value);
const y = parseInt(document.getElementById('debugClickY').value);
if (isNaN(x) || isNaN(y)) return;
const res = await fetch('/api/debug/click', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ x, y }),
});
const data = await res.json();
showDebugResult(data.ok ? `Clicked at (${x}, ${y})` : `Error: ${data.error}`);
}
function showDebugResult(text) {
document.getElementById('debugResult').textContent = text;
}
// Enter key in debug text input
document.getElementById('debugTextInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') debugFindText();
});
// Enter key in URL input
document.getElementById('urlInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') addLink();
});
connect();
</script>
</body>
</html>