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

22
.env.example Normal file
View file

@ -0,0 +1,22 @@
# POE2 Trade Bot Configuration
# Path to POE2 Client.txt log file
POE2_LOG_PATH=C:\Program Files (x86)\Steam\steamapps\common\Path of Exile 2\logs\Client.txt
# POE2 game window title (used to find/focus the window)
POE2_WINDOW_TITLE=Path of Exile 2
# Playwright persistent browser data directory
BROWSER_USER_DATA_DIR=./browser-data
# Trade URLs (comma-separated) - can also add via dashboard
# TRADE_URLS=https://www.pathofexile.com/trade2/search/poe2/Fate%20of%20the%20Vaal/LgLZ6MBGHn/live
# Dashboard port
DASHBOARD_PORT=3000
# Timeouts (milliseconds)
TRAVEL_TIMEOUT_MS=15000
STASH_SCAN_TIMEOUT_MS=10000
WAIT_FOR_MORE_ITEMS_MS=20000
BETWEEN_TRADES_DELAY_MS=5000

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
node_modules/
dist/
.env
config.json
browser-data/
*.log
debug-screenshots/
eng.traineddata
.claude/
# OcrDaemon build output
tools/OcrDaemon/bin/
tools/OcrDaemon/obj/

2888
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

32
package.json Normal file
View file

@ -0,0 +1,32 @@
{
"name": "poe2trade",
"version": "1.0.0",
"description": "POE2 trade bot - automated item purchasing via trade site monitoring",
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc",
"build:daemon": "dotnet build tools/OcrDaemon -c Release",
"start": "node dist/index.js"
},
"dependencies": {
"chokidar": "^4.0.3",
"clipboard-sys": "^1.2.0",
"commander": "^13.1.0",
"dotenv": "^16.4.7",
"express": "^5.2.1",
"koffi": "^2.9.2",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"playwright": "^1.50.1",
"ws": "^8.19.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^22.13.1",
"@types/ws": "^8.18.1",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

38
src/config.ts Normal file
View file

@ -0,0 +1,38 @@
import dotenv from 'dotenv';
import type { Config } from './types.js';
dotenv.config();
function env(key: string, fallback?: string): string {
const val = process.env[key];
if (val !== undefined) return val;
if (fallback !== undefined) return fallback;
throw new Error(`Missing required environment variable: ${key}`);
}
function envInt(key: string, fallback: number): number {
const val = process.env[key];
return val ? parseInt(val, 10) : fallback;
}
export function loadConfig(cliUrls?: string[]): Config {
const envUrls = process.env['TRADE_URLS']
? process.env['TRADE_URLS'].split(',').map((u) => u.trim())
: [];
const tradeUrls = cliUrls && cliUrls.length > 0 ? cliUrls : envUrls;
return {
tradeUrls,
poe2LogPath: env(
'POE2_LOG_PATH',
'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt',
),
poe2WindowTitle: env('POE2_WINDOW_TITLE', 'Path of Exile 2'),
browserUserDataDir: env('BROWSER_USER_DATA_DIR', './browser-data'),
travelTimeoutMs: envInt('TRAVEL_TIMEOUT_MS', 15000),
stashScanTimeoutMs: envInt('STASH_SCAN_TIMEOUT_MS', 10000),
waitForMoreItemsMs: envInt('WAIT_FOR_MORE_ITEMS_MS', 20000),
betweenTradesDelayMs: envInt('BETWEEN_TRADES_DELAY_MS', 5000),
};
}

View file

@ -0,0 +1,187 @@
import { EventEmitter } from 'events';
import { logger } from '../util/logger.js';
import type { ConfigStore, SavedLink } from './ConfigStore.js';
export interface TradeLink {
id: string;
url: string;
name: string;
label: string;
active: boolean;
addedAt: string;
}
export interface BotStatus {
paused: boolean;
state: string;
links: TradeLink[];
tradesCompleted: number;
tradesFailed: number;
uptime: number;
settings: {
poe2LogPath: string;
poe2WindowTitle: string;
travelTimeoutMs: number;
waitForMoreItemsMs: number;
betweenTradesDelayMs: number;
};
}
export class BotController extends EventEmitter {
private paused = false;
private links: Map<string, TradeLink> = new Map();
private _state = 'IDLE';
private tradesCompleted = 0;
private tradesFailed = 0;
private startTime = Date.now();
private store: ConfigStore;
constructor(store: ConfigStore) {
super();
this.store = store;
this.paused = store.settings.paused;
}
get isPaused(): boolean {
return this.paused;
}
get state(): string {
return this._state;
}
set state(s: string) {
this._state = s;
this.emit('state-change', s);
}
pause(): void {
this.paused = true;
this.store.setPaused(true);
logger.info('Bot paused');
this.emit('paused');
}
resume(): void {
this.paused = false;
this.store.setPaused(false);
logger.info('Bot resumed');
this.emit('resumed');
}
addLink(url: string, name: string = ''): TradeLink {
url = this.stripLive(url);
const id = this.extractId(url);
const label = this.extractLabel(url);
// Check if we have saved state for this link
const savedLink = this.store.links.find((l) => l.url === url);
const link: TradeLink = {
id,
url,
name: name || savedLink?.name || '',
label,
active: savedLink?.active !== undefined ? savedLink.active : true,
addedAt: new Date().toISOString(),
};
this.links.set(id, link);
this.store.addLink(url, link.name);
logger.info({ id, url, name: link.name, active: link.active }, 'Trade link added');
this.emit('link-added', link);
return link;
}
removeLink(id: string): void {
const link = this.links.get(id);
this.links.delete(id);
if (link) {
this.store.removeLink(link.url);
} else {
this.store.removeLinkById(id);
}
logger.info({ id }, 'Trade link removed');
this.emit('link-removed', id);
}
toggleLink(id: string, active: boolean): void {
const link = this.links.get(id);
if (!link) return;
link.active = active;
this.store.updateLinkById(id, { active });
logger.info({ id, active }, `Trade link ${active ? 'activated' : 'deactivated'}`);
this.emit('link-toggled', { id, active, link });
}
updateLinkName(id: string, name: string): void {
const link = this.links.get(id);
if (!link) return;
link.name = name;
this.store.updateLinkById(id, { name });
}
isLinkActive(searchId: string): boolean {
const link = this.links.get(searchId);
return link ? link.active : false;
}
getLinks(): TradeLink[] {
return Array.from(this.links.values());
}
recordTradeSuccess(): void {
this.tradesCompleted++;
this.emit('trade-completed');
}
recordTradeFailure(): void {
this.tradesFailed++;
this.emit('trade-failed');
}
getStatus(): BotStatus {
const s = this.store.settings;
return {
paused: this.paused,
state: this._state,
links: this.getLinks(),
tradesCompleted: this.tradesCompleted,
tradesFailed: this.tradesFailed,
uptime: Date.now() - this.startTime,
settings: {
poe2LogPath: s.poe2LogPath,
poe2WindowTitle: s.poe2WindowTitle,
travelTimeoutMs: s.travelTimeoutMs,
waitForMoreItemsMs: s.waitForMoreItemsMs,
betweenTradesDelayMs: s.betweenTradesDelayMs,
},
};
}
getStore(): ConfigStore {
return this.store;
}
private stripLive(url: string): string {
return url.replace(/\/live\/?$/, '');
}
private extractId(url: string): string {
const parts = url.split('/');
return parts[parts.length - 1] || url;
}
private extractLabel(url: string): string {
try {
const urlObj = new URL(url);
const parts = urlObj.pathname.split('/').filter(Boolean);
const poe2Idx = parts.indexOf('poe2');
if (poe2Idx >= 0 && parts.length > poe2Idx + 2) {
const league = decodeURIComponent(parts[poe2Idx + 1]);
const searchId = parts[poe2Idx + 2];
return `${league} / ${searchId}`;
}
} catch {
// fallback
}
return url.substring(0, 60);
}
}

View file

@ -0,0 +1,129 @@
import { readFileSync, writeFileSync, existsSync } from 'fs';
import path from 'path';
import { logger } from '../util/logger.js';
export interface SavedLink {
url: string;
name: string;
active: boolean;
addedAt: string;
}
export interface SavedSettings {
paused: boolean;
links: SavedLink[];
poe2LogPath: string;
poe2WindowTitle: string;
browserUserDataDir: string;
travelTimeoutMs: number;
stashScanTimeoutMs: number;
waitForMoreItemsMs: number;
betweenTradesDelayMs: number;
dashboardPort: number;
}
const DEFAULTS: SavedSettings = {
paused: false,
links: [],
poe2LogPath: 'C:\\Program Files (x86)\\Steam\\steamapps\\common\\Path of Exile 2\\logs\\Client.txt',
poe2WindowTitle: 'Path of Exile 2',
browserUserDataDir: './browser-data',
travelTimeoutMs: 15000,
stashScanTimeoutMs: 10000,
waitForMoreItemsMs: 20000,
betweenTradesDelayMs: 5000,
dashboardPort: 3000,
};
export class ConfigStore {
private filePath: string;
private data: SavedSettings;
constructor(configPath?: string) {
this.filePath = configPath || path.resolve('config.json');
this.data = this.load();
}
private load(): SavedSettings {
if (!existsSync(this.filePath)) {
logger.info({ path: this.filePath }, 'No config.json found, using defaults');
return { ...DEFAULTS };
}
try {
const raw = readFileSync(this.filePath, 'utf-8');
const parsed = JSON.parse(raw) as Partial<SavedSettings>;
const merged = { ...DEFAULTS, ...parsed };
// Migrate old links: add name/active fields, strip /live from URLs
merged.links = merged.links.map((l) => ({
url: l.url.replace(/\/live\/?$/, ''),
name: l.name || '',
active: l.active !== undefined ? l.active : true,
addedAt: l.addedAt || new Date().toISOString(),
}));
logger.info({ path: this.filePath, linkCount: merged.links.length }, 'Loaded config.json');
return merged;
} catch (err) {
logger.warn({ err, path: this.filePath }, 'Failed to read config.json, using defaults');
return { ...DEFAULTS };
}
}
save(): void {
try {
writeFileSync(this.filePath, JSON.stringify(this.data, null, 2), 'utf-8');
} catch (err) {
logger.error({ err, path: this.filePath }, 'Failed to save config.json');
}
}
get settings(): SavedSettings {
return this.data;
}
get links(): SavedLink[] {
return this.data.links;
}
addLink(url: string, name: string = ''): void {
url = url.replace(/\/live\/?$/, '');
if (this.data.links.some((l) => l.url === url)) return;
this.data.links.push({ url, name, active: true, addedAt: new Date().toISOString() });
this.save();
}
removeLink(url: string): void {
this.data.links = this.data.links.filter((l) => l.url !== url);
this.save();
}
removeLinkById(id: string): void {
this.data.links = this.data.links.filter((l) => {
const parts = l.url.split('/');
return parts[parts.length - 1] !== id;
});
this.save();
}
updateLinkById(id: string, updates: { name?: string; active?: boolean }): SavedLink | null {
const link = this.data.links.find((l) => {
const parts = l.url.split('/');
return parts[parts.length - 1] === id;
});
if (!link) return null;
if (updates.name !== undefined) link.name = updates.name;
if (updates.active !== undefined) link.active = updates.active;
this.save();
return link;
}
setPaused(paused: boolean): void {
this.data.paused = paused;
this.save();
}
updateSettings(partial: Partial<SavedSettings>): void {
Object.assign(this.data, partial);
this.save();
}
}

View file

@ -0,0 +1,227 @@
import express from 'express';
import http from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import path from 'path';
import { fileURLToPath } from 'url';
import { logger } from '../util/logger.js';
import type { BotController } from './BotController.js';
import type { ScreenReader } from '../game/ScreenReader.js';
import type { GameController } from '../game/GameController.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export interface DebugDeps {
screenReader: ScreenReader;
gameController: GameController;
}
export class DashboardServer {
private app = express();
private server: http.Server;
private wss: WebSocketServer;
private clients: Set<WebSocket> = new Set();
private bot: BotController;
private debug: DebugDeps | null = null;
constructor(bot: BotController, private port: number = 3000) {
this.bot = bot;
this.app.use(express.json());
this.app.get('/', (_req, res) => {
res.sendFile(path.join(__dirname, '..', '..', 'src', 'dashboard', 'index.html'));
});
// Status
this.app.get('/api/status', (_req, res) => {
res.json(this.bot.getStatus());
});
// Pause / Resume
this.app.post('/api/pause', (_req, res) => {
this.bot.pause();
this.broadcastStatus();
res.json({ ok: true });
});
this.app.post('/api/resume', (_req, res) => {
this.bot.resume();
this.broadcastStatus();
res.json({ ok: true });
});
// Links CRUD
this.app.post('/api/links', (req, res) => {
const { url, name } = req.body as { url: string; name?: string };
if (!url || !url.includes('pathofexile.com/trade')) {
res.status(400).json({ error: 'Invalid trade URL' });
return;
}
this.bot.addLink(url, name || '');
this.broadcastStatus();
res.json({ ok: true });
});
this.app.delete('/api/links/:id', (req, res) => {
this.bot.removeLink(req.params.id);
this.broadcastStatus();
res.json({ ok: true });
});
// Toggle link active/inactive
this.app.post('/api/links/:id/toggle', (req, res) => {
const { active } = req.body as { active: boolean };
this.bot.toggleLink(req.params.id, active);
this.broadcastStatus();
res.json({ ok: true });
});
// Rename link
this.app.post('/api/links/:id/name', (req, res) => {
const { name } = req.body as { name: string };
this.bot.updateLinkName(req.params.id, name);
this.broadcastStatus();
res.json({ ok: true });
});
// Settings
this.app.post('/api/settings', (req, res) => {
const updates = req.body as Record<string, unknown>;
const store = this.bot.getStore();
store.updateSettings(updates);
this.broadcastStatus();
res.json({ ok: true });
});
// Debug endpoints
this.app.post('/api/debug/screenshot', async (_req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
try {
const files = await this.debug.screenReader.saveDebugScreenshots('debug-screenshots');
this.broadcastLog('info', `Debug screenshots saved: ${files.map(f => f.split(/[\\/]/).pop()).join(', ')}`);
res.json({ ok: true, files });
} catch (err) {
logger.error({ err }, 'Debug screenshot failed');
res.status(500).json({ error: 'Screenshot failed' });
}
});
this.app.post('/api/debug/ocr', async (_req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
try {
const text = await this.debug.screenReader.readFullScreen();
this.broadcastLog('info', `OCR result (${text.length} chars): ${text.substring(0, 200)}`);
res.json({ ok: true, text });
} catch (err) {
logger.error({ err }, 'Debug OCR failed');
res.status(500).json({ error: 'OCR failed' });
}
});
this.app.post('/api/debug/find-text', async (req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
const { text } = req.body as { text: string };
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
try {
const pos = await this.debug.screenReader.findTextOnScreen(text);
if (pos) {
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y})`);
} else {
this.broadcastLog('warn', `"${text}" not found on screen`);
}
res.json({ ok: true, found: !!pos, position: pos });
} catch (err) {
logger.error({ err }, 'Debug find-text failed');
res.status(500).json({ error: 'Find text failed' });
}
});
this.app.post('/api/debug/click', async (req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
const { x, y } = req.body as { x: number; y: number };
if (x == null || y == null) { res.status(400).json({ error: 'Missing x/y' }); return; }
try {
await this.debug.gameController.focusGame();
await this.debug.gameController.leftClickAt(x, y);
this.broadcastLog('info', `Clicked at (${x}, ${y})`);
res.json({ ok: true });
} catch (err) {
logger.error({ err }, 'Debug click failed');
res.status(500).json({ error: 'Click failed' });
}
});
this.app.post('/api/debug/find-and-click', async (req, res) => {
if (!this.debug) { res.status(503).json({ error: 'Debug not available' }); return; }
const { text } = req.body as { text: string };
if (!text) { res.status(400).json({ error: 'Missing text parameter' }); return; }
try {
const pos = await this.debug.screenReader.findTextOnScreen(text);
if (pos) {
await this.debug.gameController.focusGame();
await this.debug.gameController.leftClickAt(pos.x, pos.y);
this.broadcastLog('info', `Found "${text}" at (${pos.x}, ${pos.y}) and clicked`);
res.json({ ok: true, found: true, position: pos });
} else {
this.broadcastLog('warn', `"${text}" not found on screen`);
res.json({ ok: true, found: false, position: null });
}
} catch (err) {
logger.error({ err }, 'Debug find-and-click failed');
res.status(500).json({ error: 'Find and click failed' });
}
});
this.server = http.createServer(this.app);
this.wss = new WebSocketServer({ server: this.server });
this.wss.on('connection', (ws) => {
this.clients.add(ws);
ws.send(JSON.stringify({ type: 'status', data: this.bot.getStatus() }));
ws.on('close', () => this.clients.delete(ws));
});
}
setDebugDeps(deps: DebugDeps): void {
this.debug = deps;
logger.info('Debug tools available on dashboard');
}
broadcastStatus(): void {
const msg = JSON.stringify({ type: 'status', data: this.bot.getStatus() });
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}
broadcastLog(level: string, message: string): void {
const msg = JSON.stringify({
type: 'log',
data: { level, message, time: new Date().toISOString() },
});
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(msg);
}
}
}
async start(): Promise<void> {
return new Promise((resolve) => {
this.server.listen(this.port, () => {
logger.info({ port: this.port }, `Dashboard running at http://localhost:${this.port}`);
resolve();
});
});
}
async stop(): Promise<void> {
for (const client of this.clients) {
client.close();
}
return new Promise((resolve) => {
this.server.close(() => resolve());
});
}
}

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>

View file

@ -0,0 +1,251 @@
import { GameController } from '../game/GameController.js';
import { ScreenReader } from '../game/ScreenReader.js';
import { ClientLogWatcher } from '../log/ClientLogWatcher.js';
import { TradeMonitor } from '../trade/TradeMonitor.js';
import { sleep, randomDelay } from '../util/sleep.js';
import { logger } from '../util/logger.js';
import type { Config, TradeInfo, TradeState, Region } from '../types.js';
import type { Page } from 'playwright';
// Default screen regions for 1920x1080 - these need calibration
const DEFAULT_REGIONS = {
stashArea: { x: 20, y: 140, width: 630, height: 750 },
priceWarningDialog: { x: 600, y: 350, width: 700, height: 300 },
priceWarningNoButton: { x: 820, y: 560, width: 120, height: 40 },
inventoryArea: { x: 1260, y: 580, width: 630, height: 280 },
stashTabArea: { x: 20, y: 100, width: 630, height: 40 },
};
export class TradeExecutor {
private state: TradeState = 'IDLE';
private gameController: GameController;
private screenReader: ScreenReader;
private logWatcher: ClientLogWatcher;
private tradeMonitor: TradeMonitor;
private config: Config;
constructor(
gameController: GameController,
screenReader: ScreenReader,
logWatcher: ClientLogWatcher,
tradeMonitor: TradeMonitor,
config: Config,
) {
this.gameController = gameController;
this.screenReader = screenReader;
this.logWatcher = logWatcher;
this.tradeMonitor = tradeMonitor;
this.config = config;
}
getState(): TradeState {
return this.state;
}
async executeTrade(trade: TradeInfo): Promise<boolean> {
const page = trade.page as Page;
try {
// Step 1: Click "Travel to Hideout" on the trade website
this.state = 'TRAVELING';
logger.info({ searchId: trade.searchId }, 'Clicking Travel to Hideout...');
const travelClicked = await this.tradeMonitor.clickTravelToHideout(
page,
trade.itemIds[0],
);
if (!travelClicked) {
logger.error('Failed to click Travel to Hideout');
this.state = 'FAILED';
return false;
}
// Step 2: Wait for area transition (arrival at seller's hideout)
logger.info('Waiting for area transition...');
const arrived = await this.waitForAreaTransition(this.config.travelTimeoutMs);
if (!arrived) {
logger.error('Timed out waiting for hideout arrival');
this.state = 'FAILED';
return false;
}
this.state = 'IN_SELLERS_HIDEOUT';
logger.info('Arrived at seller hideout');
// Step 3: Focus game window and click on Ange then Stash
await this.gameController.focusGame();
await sleep(1500); // Wait for hideout to render
// Click on Ange NPC to interact
const angePos = await this.findAndClickNameplate('Ange');
if (!angePos) {
logger.warn('Could not find Ange nameplate, trying Stash directly');
} else {
await sleep(1000); // Wait for NPC interaction
}
// Click on Stash to open it
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate in seller hideout');
this.state = 'FAILED';
return false;
}
await sleep(1000); // Wait for stash to open
// Step 4: Scan stash and buy items
this.state = 'SCANNING_STASH';
logger.info('Scanning stash for items...');
await this.scanAndBuyItems();
// Step 5: Wait for more items
this.state = 'WAITING_FOR_MORE';
logger.info(
{ waitMs: this.config.waitForMoreItemsMs },
'Waiting for seller to add more items...',
);
await sleep(this.config.waitForMoreItemsMs);
// Do one more scan after waiting
await this.scanAndBuyItems();
// Step 6: Go back to own hideout
this.state = 'GOING_HOME';
logger.info('Traveling to own hideout...');
await this.gameController.focusGame();
await sleep(300);
await this.gameController.goToHideout();
const home = await this.waitForAreaTransition(this.config.travelTimeoutMs);
if (!home) {
logger.warn('Timed out going home, continuing anyway...');
}
// Step 7: Store items in stash
this.state = 'IN_HIDEOUT';
await sleep(1000);
await this.storeItems();
this.state = 'IDLE';
return true;
} catch (err) {
logger.error({ err }, 'Trade execution failed');
this.state = 'FAILED';
// Try to recover by going home
try {
await this.gameController.focusGame();
await this.gameController.pressEscape(); // Close any open dialogs
await sleep(500);
await this.gameController.goToHideout();
} catch {
// Best-effort recovery
}
this.state = 'IDLE';
return false;
}
}
private async scanAndBuyItems(): Promise<void> {
// Take a screenshot of the stash area
const stashText = await this.screenReader.readRegionText(DEFAULT_REGIONS.stashArea);
logger.info({ stashText: stashText.substring(0, 200) }, 'Stash OCR result');
// For now, we'll use a simple grid-based approach to click items
// The exact positions depend on the stash layout and resolution
// This needs calibration with real game screenshots
//
// TODO: Implement item matching logic based on OCR text
// For now, we'll Ctrl+right-click at known grid positions
this.state = 'BUYING';
// Check for price warning dialog after each buy
await this.checkPriceWarning();
}
private async checkPriceWarning(): Promise<void> {
// Check if a price warning dialog appeared
const hasWarning = await this.screenReader.checkForText(
DEFAULT_REGIONS.priceWarningDialog,
'price',
);
if (hasWarning) {
logger.warn('Price mismatch warning detected! Clicking No.');
// Click the "No" button
await this.gameController.leftClickAt(
DEFAULT_REGIONS.priceWarningNoButton.x + DEFAULT_REGIONS.priceWarningNoButton.width / 2,
DEFAULT_REGIONS.priceWarningNoButton.y + DEFAULT_REGIONS.priceWarningNoButton.height / 2,
);
await sleep(500);
}
}
private async storeItems(): Promise<void> {
logger.info('Storing purchased items in stash...');
// Focus game and find Stash in own hideout
await this.gameController.focusGame();
await sleep(500);
const stashPos = await this.findAndClickNameplate('Stash');
if (!stashPos) {
logger.error('Could not find Stash nameplate in own hideout');
return;
}
await sleep(1000); // Wait for stash to open
// Open inventory
await this.gameController.openInventory();
await sleep(500);
// TODO: Implement inventory scanning to find purchased items
// and Ctrl+right-click each to transfer to stash
logger.info('Item storage complete (needs calibration)');
}
private async findAndClickNameplate(
name: string,
maxRetries: number = 3,
retryDelayMs: number = 1000,
): Promise<{ x: number; y: number } | null> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
logger.info({ name, attempt, maxRetries }, 'Searching for nameplate...');
const pos = await this.screenReader.findTextOnScreen(name);
if (pos) {
logger.info({ name, x: pos.x, y: pos.y }, 'Clicking nameplate');
await this.gameController.leftClickAt(pos.x, pos.y);
return pos;
}
if (attempt < maxRetries) {
logger.debug({ name, attempt }, 'Nameplate not found, retrying...');
await sleep(retryDelayMs);
}
}
logger.warn({ name, maxRetries }, 'Nameplate not found after all retries');
return null;
}
private waitForAreaTransition(timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
const timer = setTimeout(() => {
this.logWatcher.removeListener('area-entered', handler);
resolve(false);
}, timeoutMs);
const handler = () => {
clearTimeout(timer);
resolve(true);
};
this.logWatcher.once('area-entered', handler);
});
}
}

View file

@ -0,0 +1,69 @@
import { logger } from '../util/logger.js';
import { sleep, randomDelay } from '../util/sleep.js';
import type { TradeExecutor } from './TradeExecutor.js';
import type { TradeInfo, Config } from '../types.js';
export class TradeQueue {
private queue: TradeInfo[] = [];
private processing = false;
constructor(
private executor: TradeExecutor,
private config: Config,
) {}
enqueue(trade: TradeInfo): void {
// De-duplicate: skip if same item ID already queued
const existingIds = new Set(this.queue.flatMap((t) => t.itemIds));
const newIds = trade.itemIds.filter((id) => !existingIds.has(id));
if (newIds.length === 0) {
logger.info({ itemIds: trade.itemIds }, 'Skipping duplicate trade');
return;
}
const dedupedTrade = { ...trade, itemIds: newIds };
this.queue.push(dedupedTrade);
logger.info(
{ itemIds: newIds, queueLength: this.queue.length },
'Trade enqueued',
);
this.processNext();
}
get length(): number {
return this.queue.length;
}
get isProcessing(): boolean {
return this.processing;
}
private async processNext(): Promise<void> {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const trade = this.queue.shift()!;
try {
logger.info(
{ searchId: trade.searchId, itemIds: trade.itemIds },
'Processing trade',
);
const success = await this.executor.executeTrade(trade);
if (success) {
logger.info({ itemIds: trade.itemIds }, 'Trade completed successfully');
} else {
logger.warn({ itemIds: trade.itemIds }, 'Trade failed');
}
} catch (err) {
logger.error({ err, itemIds: trade.itemIds }, 'Trade execution error');
}
this.processing = false;
// Delay between trades
await randomDelay(this.config.betweenTradesDelayMs, this.config.betweenTradesDelayMs + 3000);
this.processNext();
}
}

107
src/game/GameController.ts Normal file
View file

@ -0,0 +1,107 @@
import { WindowManager } from './WindowManager.js';
import { InputSender, VK } from './InputSender.js';
import { sleep, randomDelay } from '../util/sleep.js';
import { writeClipboard } from '../util/clipboard.js';
import { logger } from '../util/logger.js';
import type { Config } from '../types.js';
export class GameController {
private windowManager: WindowManager;
private inputSender: InputSender;
constructor(config: Config) {
this.windowManager = new WindowManager(config.poe2WindowTitle);
this.inputSender = new InputSender();
}
async focusGame(): Promise<boolean> {
const result = this.windowManager.focusWindow();
if (result) {
await sleep(300); // Wait for window to actually focus
}
return result;
}
isGameFocused(): boolean {
return this.windowManager.isGameFocused();
}
getWindowRect() {
return this.windowManager.getWindowRect();
}
async sendChat(message: string): Promise<void> {
logger.info({ message }, 'Sending chat message');
// Open chat
await this.inputSender.pressKey(VK.RETURN);
await randomDelay(100, 200);
// Clear any existing text
await this.inputSender.selectAll();
await sleep(50);
await this.inputSender.pressKey(VK.DELETE);
await sleep(50);
// Type the message
await this.inputSender.typeText(message);
await randomDelay(50, 100);
// Send
await this.inputSender.pressKey(VK.RETURN);
await sleep(100);
}
async sendChatViaPaste(message: string): Promise<void> {
logger.info({ message }, 'Sending chat message via paste');
// Copy message to clipboard
writeClipboard(message);
await sleep(50);
// Open chat
await this.inputSender.pressKey(VK.RETURN);
await randomDelay(100, 200);
// Clear any existing text
await this.inputSender.selectAll();
await sleep(50);
await this.inputSender.pressKey(VK.DELETE);
await sleep(50);
// Paste
await this.inputSender.paste();
await randomDelay(100, 200);
// Send
await this.inputSender.pressKey(VK.RETURN);
await sleep(100);
}
async goToHideout(): Promise<void> {
logger.info('Sending /hideout command');
await this.sendChat('/hideout');
}
async ctrlRightClickAt(x: number, y: number): Promise<void> {
await this.inputSender.ctrlRightClick(x, y);
}
async leftClickAt(x: number, y: number): Promise<void> {
await this.inputSender.leftClick(x, y);
}
async rightClickAt(x: number, y: number): Promise<void> {
await this.inputSender.rightClick(x, y);
}
async pressEscape(): Promise<void> {
await this.inputSender.pressKey(VK.ESCAPE);
}
async openInventory(): Promise<void> {
logger.info('Opening inventory');
await this.inputSender.pressKey(VK.I);
await sleep(300);
}
}

294
src/game/InputSender.ts Normal file
View file

@ -0,0 +1,294 @@
import koffi from 'koffi';
import { sleep, randomDelay } from '../util/sleep.js';
// Win32 POINT struct for GetCursorPos
const POINT = koffi.struct('POINT', { x: 'int32', y: 'int32' });
// Win32 INPUT struct on x64 is 40 bytes:
// type (4) + pad (4) + union (32)
// MOUSEINPUT is 32 bytes (the largest union member)
// KEYBDINPUT is 24 bytes, so needs 8 bytes trailing pad in the union
//
// We define flat structs that match the exact memory layout,
// then cast with koffi.as() when calling SendInput.
const INPUT_KEYBOARD = koffi.struct('INPUT_KEYBOARD', {
type: 'uint32', // offset 0
_pad0: 'uint32', // offset 4 (alignment for union at offset 8)
wVk: 'uint16', // offset 8
wScan: 'uint16', // offset 10
dwFlags: 'uint32', // offset 12
time: 'uint32', // offset 16
_pad1: 'uint32', // offset 20 (alignment for dwExtraInfo)
dwExtraInfo: 'uint64', // offset 24
_pad2: koffi.array('uint8', 8), // offset 32, pad to 40 bytes total
});
const INPUT_MOUSE = koffi.struct('INPUT_MOUSE', {
type: 'uint32', // offset 0
_pad0: 'uint32', // offset 4 (alignment for union at offset 8)
dx: 'int32', // offset 8
dy: 'int32', // offset 12
mouseData: 'uint32', // offset 16
dwFlags: 'uint32', // offset 20
time: 'uint32', // offset 24
_pad1: 'uint32', // offset 28 (alignment for dwExtraInfo)
dwExtraInfo: 'uint64', // offset 32
});
// INPUT_MOUSE is already 40 bytes, no trailing pad needed
const user32 = koffi.load('user32.dll');
const SendInput = user32.func('SendInput', 'uint32', ['uint32', 'void *', 'int32']);
const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']);
const GetSystemMetrics = user32.func('GetSystemMetrics', 'int32', ['int32']);
const GetCursorPos = user32.func('GetCursorPos', 'int32', ['_Out_ POINT *']);
// Constants
const INPUT_MOUSE_TYPE = 0;
const INPUT_KEYBOARD_TYPE = 1;
const KEYEVENTF_SCANCODE = 0x0008;
const KEYEVENTF_KEYUP = 0x0002;
const KEYEVENTF_UNICODE = 0x0004;
// Mouse flags
const MOUSEEVENTF_MOVE = 0x0001;
const MOUSEEVENTF_LEFTDOWN = 0x0002;
const MOUSEEVENTF_LEFTUP = 0x0004;
const MOUSEEVENTF_RIGHTDOWN = 0x0008;
const MOUSEEVENTF_RIGHTUP = 0x0010;
const MOUSEEVENTF_ABSOLUTE = 0x8000;
// System metrics
const SM_CXSCREEN = 0;
const SM_CYSCREEN = 1;
// Virtual key codes
export const VK = {
RETURN: 0x0d,
CONTROL: 0x11,
MENU: 0x12, // Alt
SHIFT: 0x10,
ESCAPE: 0x1b,
TAB: 0x09,
SPACE: 0x20,
DELETE: 0x2e,
BACK: 0x08,
V: 0x56,
A: 0x41,
C: 0x43,
I: 0x49,
} as const;
// Size to pass to SendInput (must be sizeof(INPUT) = 40 on x64)
const INPUT_SIZE = koffi.sizeof(INPUT_MOUSE); // 40
// Bézier curve helpers for natural mouse movement
function easeInOutQuad(t: number): number {
return t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;
}
interface Point {
x: number;
y: number;
}
function cubicBezier(t: number, p0: Point, p1: Point, p2: Point, p3: Point): Point {
const u = 1 - t;
const u2 = u * u;
const u3 = u2 * u;
const t2 = t * t;
const t3 = t2 * t;
return {
x: u3 * p0.x + 3 * u2 * t * p1.x + 3 * u * t2 * p2.x + t3 * p3.x,
y: u3 * p0.y + 3 * u2 * t * p1.y + 3 * u * t2 * p2.y + t3 * p3.y,
};
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export class InputSender {
private screenWidth: number;
private screenHeight: number;
constructor() {
this.screenWidth = GetSystemMetrics(SM_CXSCREEN);
this.screenHeight = GetSystemMetrics(SM_CYSCREEN);
}
async pressKey(vkCode: number): Promise<void> {
const scanCode = MapVirtualKeyW(vkCode, 0); // MAPVK_VK_TO_VSC
this.sendScanKeyDown(scanCode);
await randomDelay(30, 50);
this.sendScanKeyUp(scanCode);
await randomDelay(20, 40);
}
async keyDown(vkCode: number): Promise<void> {
const scanCode = MapVirtualKeyW(vkCode, 0);
this.sendScanKeyDown(scanCode);
await randomDelay(15, 30);
}
async keyUp(vkCode: number): Promise<void> {
const scanCode = MapVirtualKeyW(vkCode, 0);
this.sendScanKeyUp(scanCode);
await randomDelay(15, 30);
}
async typeText(text: string): Promise<void> {
for (const char of text) {
this.sendUnicodeChar(char);
await randomDelay(20, 50);
}
}
async paste(): Promise<void> {
await this.keyDown(VK.CONTROL);
await sleep(30);
await this.pressKey(VK.V);
await this.keyUp(VK.CONTROL);
await sleep(50);
}
async selectAll(): Promise<void> {
await this.keyDown(VK.CONTROL);
await sleep(30);
await this.pressKey(VK.A);
await this.keyUp(VK.CONTROL);
await sleep(50);
}
private getCursorPos(): Point {
const pt = { x: 0, y: 0 };
GetCursorPos(pt);
return pt;
}
private moveMouseRaw(x: number, y: number): void {
const normalizedX = Math.round((x * 65535) / this.screenWidth);
const normalizedY = Math.round((y * 65535) / this.screenHeight);
this.sendMouseInput(normalizedX, normalizedY, 0, MOUSEEVENTF_MOVE | MOUSEEVENTF_ABSOLUTE);
}
async moveMouse(x: number, y: number): Promise<void> {
const start = this.getCursorPos();
const end: Point = { x, y };
const dx = end.x - start.x;
const dy = end.y - start.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Short distance: just teleport
if (distance < 10) {
this.moveMouseRaw(x, y);
await randomDelay(10, 20);
return;
}
// Generate 2 random control points offset from the straight line
const perpX = -dy / distance;
const perpY = dx / distance;
const spread = distance * 0.3;
const cp1: Point = {
x: start.x + dx * 0.25 + perpX * (Math.random() - 0.5) * spread,
y: start.y + dy * 0.25 + perpY * (Math.random() - 0.5) * spread,
};
const cp2: Point = {
x: start.x + dx * 0.75 + perpX * (Math.random() - 0.5) * spread,
y: start.y + dy * 0.75 + perpY * (Math.random() - 0.5) * spread,
};
const steps = clamp(Math.round(distance / 15), 15, 40);
for (let i = 1; i <= steps; i++) {
const rawT = i / steps;
const t = easeInOutQuad(rawT);
const pt = cubicBezier(t, start, cp1, cp2, end);
// Add ±1px jitter except on the last step
const jitterX = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0;
const jitterY = i < steps ? Math.round((Math.random() - 0.5) * 2) : 0;
this.moveMouseRaw(Math.round(pt.x) + jitterX, Math.round(pt.y) + jitterY);
await sleep(2 + Math.random() * 3); // 2-5ms between steps
}
// Final exact landing
this.moveMouseRaw(x, y);
await randomDelay(10, 25);
}
async leftClick(x: number, y: number): Promise<void> {
await this.moveMouse(x, y);
await randomDelay(50, 100);
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTDOWN);
await randomDelay(30, 80);
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_LEFTUP);
await randomDelay(30, 60);
}
async rightClick(x: number, y: number): Promise<void> {
await this.moveMouse(x, y);
await randomDelay(50, 100);
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTDOWN);
await randomDelay(30, 80);
this.sendMouseInput(0, 0, 0, MOUSEEVENTF_RIGHTUP);
await randomDelay(30, 60);
}
async ctrlRightClick(x: number, y: number): Promise<void> {
await this.keyDown(VK.CONTROL);
await randomDelay(30, 60);
await this.rightClick(x, y);
await this.keyUp(VK.CONTROL);
await randomDelay(30, 60);
}
private sendMouseInput(dx: number, dy: number, mouseData: number, flags: number): void {
const input = {
type: INPUT_MOUSE_TYPE,
_pad0: 0,
dx,
dy,
mouseData,
dwFlags: flags,
time: 0,
_pad1: 0,
dwExtraInfo: 0,
};
SendInput(1, koffi.as(input, 'INPUT_MOUSE *'), INPUT_SIZE);
}
private sendKeyInput(wVk: number, wScan: number, flags: number): void {
const input = {
type: INPUT_KEYBOARD_TYPE,
_pad0: 0,
wVk,
wScan,
dwFlags: flags,
time: 0,
_pad1: 0,
dwExtraInfo: 0,
_pad2: [0, 0, 0, 0, 0, 0, 0, 0],
};
SendInput(1, koffi.as(input, 'INPUT_KEYBOARD *'), INPUT_SIZE);
}
private sendScanKeyDown(scanCode: number): void {
this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE);
}
private sendScanKeyUp(scanCode: number): void {
this.sendKeyInput(0, scanCode, KEYEVENTF_SCANCODE | KEYEVENTF_KEYUP);
}
private sendUnicodeChar(char: string): void {
const code = char.charCodeAt(0);
this.sendKeyInput(0, code, KEYEVENTF_UNICODE);
this.sendKeyInput(0, code, KEYEVENTF_UNICODE | KEYEVENTF_KEYUP);
}
}

256
src/game/OcrDaemon.ts Normal file
View file

@ -0,0 +1,256 @@
import { spawn, type ChildProcess } from 'child_process';
import { join } from 'path';
import { logger } from '../util/logger.js';
import type { Region } from '../types.js';
// ── Types ───────────────────────────────────────────────────────────────────
export interface OcrWord {
text: string;
x: number;
y: number;
width: number;
height: number;
}
export interface OcrLine {
text: string;
words: OcrWord[];
}
export interface OcrResponse {
ok: true;
text: string;
lines: OcrLine[];
}
interface DaemonRequest {
cmd: string;
region?: Region;
path?: string;
}
interface DaemonResponse {
ok: boolean;
ready?: boolean;
text?: string;
lines?: OcrLine[];
image?: string;
error?: string;
}
// ── OcrDaemon ───────────────────────────────────────────────────────────────
const DEFAULT_EXE = join(
'tools', 'OcrDaemon', 'bin', 'Release',
'net8.0-windows10.0.19041.0', 'OcrDaemon.exe',
);
const REQUEST_TIMEOUT = 5_000;
const CAPTURE_TIMEOUT = 10_000;
export class OcrDaemon {
private proc: ChildProcess | null = null;
private exePath: string;
private readyResolve: ((value: void) => void) | null = null;
private readyReject: ((err: Error) => void) | null = null;
private pendingResolve: ((resp: DaemonResponse) => void) | null = null;
private pendingReject: ((err: Error) => void) | null = null;
private queue: Array<{ request: DaemonRequest; resolve: (resp: DaemonResponse) => void; reject: (err: Error) => void }> = [];
private processing = false;
private buffer = '';
private stopped = false;
constructor(exePath?: string) {
this.exePath = exePath ?? DEFAULT_EXE;
}
// ── Public API ──────────────────────────────────────────────────────────
async ocr(region?: Region): Promise<OcrResponse> {
const req: DaemonRequest = { cmd: 'ocr' };
if (region) req.region = region;
const resp = await this.sendWithRetry(req, REQUEST_TIMEOUT);
return {
ok: true,
text: resp.text ?? '',
lines: resp.lines ?? [],
};
}
async captureBuffer(region?: Region): Promise<Buffer> {
const req: DaemonRequest = { cmd: 'capture' };
if (region) req.region = region;
const resp = await this.sendWithRetry(req, CAPTURE_TIMEOUT);
return Buffer.from(resp.image!, 'base64');
}
async saveScreenshot(path: string, region?: Region): Promise<void> {
const req: DaemonRequest = { cmd: 'screenshot', path };
if (region) req.region = region;
await this.sendWithRetry(req, REQUEST_TIMEOUT);
}
async stop(): Promise<void> {
this.stopped = true;
if (this.proc) {
const p = this.proc;
this.proc = null;
p.stdin?.end();
p.kill();
}
}
// ── Internal ────────────────────────────────────────────────────────────
private async ensureRunning(): Promise<void> {
if (this.proc && this.proc.exitCode === null) return;
this.proc = null;
this.buffer = '';
logger.info({ exe: this.exePath }, 'Spawning OCR daemon');
const proc = spawn(this.exePath, [], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
this.proc = proc;
proc.stderr?.on('data', (data: Buffer) => {
logger.warn({ daemon: data.toString().trim() }, 'OcrDaemon stderr');
});
proc.on('exit', (code) => {
logger.warn({ code }, 'OcrDaemon exited');
if (this.pendingReject) {
this.pendingReject(new Error(`Daemon exited with code ${code}`));
this.pendingResolve = null;
this.pendingReject = null;
}
});
proc.stdout!.on('data', (data: Buffer) => {
this.buffer += data.toString();
this.processBuffer();
});
// Wait for ready signal
await new Promise<void>((resolve, reject) => {
this.readyResolve = resolve;
this.readyReject = reject;
const timeout = setTimeout(() => {
this.readyReject = null;
this.readyResolve = null;
reject(new Error('Daemon did not become ready within 10s'));
}, 10_000);
// Store so we can clear on resolve
(this as any)._readyTimeout = timeout;
});
logger.info('OCR daemon ready');
}
private processBuffer(): void {
let newlineIdx: number;
while ((newlineIdx = this.buffer.indexOf('\n')) !== -1) {
const line = this.buffer.slice(0, newlineIdx).trim();
this.buffer = this.buffer.slice(newlineIdx + 1);
if (!line) continue;
let parsed: DaemonResponse;
try {
parsed = JSON.parse(line);
} catch {
logger.warn({ line }, 'Failed to parse daemon response');
continue;
}
// Handle ready signal
if (parsed.ready && this.readyResolve) {
clearTimeout((this as any)._readyTimeout);
const resolve = this.readyResolve;
this.readyResolve = null;
this.readyReject = null;
resolve();
continue;
}
// Handle normal response
if (this.pendingResolve) {
const resolve = this.pendingResolve;
this.pendingResolve = null;
this.pendingReject = null;
resolve(parsed);
}
}
}
private async send(request: DaemonRequest, timeout: number): Promise<DaemonResponse> {
await this.ensureRunning();
return new Promise<DaemonResponse>((resolve, reject) => {
this.queue.push({ request, resolve, reject });
this.drainQueue(timeout);
});
}
private drainQueue(timeout: number): void {
if (this.processing || this.queue.length === 0) return;
this.processing = true;
const { request, resolve, reject } = this.queue.shift()!;
const timer = setTimeout(() => {
this.pendingResolve = null;
this.pendingReject = null;
this.processing = false;
reject(new Error(`Daemon request timed out after ${timeout}ms`));
this.drainQueue(timeout);
}, timeout);
this.pendingResolve = (resp) => {
clearTimeout(timer);
this.processing = false;
resolve(resp);
this.drainQueue(timeout);
};
this.pendingReject = (err) => {
clearTimeout(timer);
this.processing = false;
reject(err);
this.drainQueue(timeout);
};
const json = JSON.stringify(request) + '\n';
this.proc!.stdin!.write(json);
}
private async sendWithRetry(request: DaemonRequest, timeout: number): Promise<DaemonResponse> {
try {
const resp = await this.send(request, timeout);
if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error');
return resp;
} catch (err) {
if (this.stopped) throw err;
// Kill and retry once
logger.warn({ err, cmd: request.cmd }, 'Daemon request failed, restarting');
if (this.proc) {
const p = this.proc;
this.proc = null;
p.stdin?.end();
p.kill();
}
const resp = await this.send(request, timeout);
if (!resp.ok) throw new Error(resp.error ?? 'Daemon returned error on retry');
return resp;
}
}
}

129
src/game/ScreenReader.ts Normal file
View file

@ -0,0 +1,129 @@
import { mkdir } from 'fs/promises';
import { join } from 'path';
import { logger } from '../util/logger.js';
import { OcrDaemon, type OcrResponse } from './OcrDaemon.js';
import type { Region } from '../types.js';
function elapsed(start: number): string {
return `${(performance.now() - start).toFixed(0)}ms`;
}
export class ScreenReader {
private daemon = new OcrDaemon();
// ── Screenshot capture ──────────────────────────────────────────────
async captureScreen(): Promise<Buffer> {
const t = performance.now();
const buf = await this.daemon.captureBuffer();
logger.info({ ms: elapsed(t) }, 'captureScreen');
return buf;
}
async captureRegion(region: Region): Promise<Buffer> {
const t = performance.now();
const buf = await this.daemon.captureBuffer(region);
logger.info({ ms: elapsed(t) }, 'captureRegion');
return buf;
}
// ── OCR helpers ─────────────────────────────────────────────────────
private findWordInOcrResult(
result: OcrResponse,
needle: string,
): { x: number; y: number } | null {
const lower = needle.toLowerCase();
for (const line of result.lines) {
for (const word of line.words) {
if (word.text.toLowerCase().includes(lower)) {
return {
x: Math.round(word.x + word.width / 2),
y: Math.round(word.y + word.height / 2),
};
}
}
}
return null;
}
// ── Full-screen methods ─────────────────────────────────────────────
async findTextOnScreen(
searchText: string,
): Promise<{ x: number; y: number } | null> {
const t = performance.now();
const result = await this.daemon.ocr();
const pos = this.findWordInOcrResult(result, searchText);
if (pos) {
logger.info({ searchText, x: pos.x, y: pos.y, totalMs: elapsed(t) }, 'Found text on screen');
} else {
logger.info({ searchText, totalMs: elapsed(t) }, 'Text not found on screen');
}
return pos;
}
async readFullScreen(): Promise<string> {
const result = await this.daemon.ocr();
return result.text;
}
// ── Region methods ──────────────────────────────────────────────────
async findTextInRegion(
region: Region,
searchText: string,
): Promise<{ x: number; y: number } | null> {
const t = performance.now();
const result = await this.daemon.ocr(region);
const pos = this.findWordInOcrResult(result, searchText);
if (pos) {
// Offset back to screen space
const screenPos = { x: region.x + pos.x, y: region.y + pos.y };
logger.info({ searchText, x: screenPos.x, y: screenPos.y, region, totalMs: elapsed(t) }, 'Found text in region');
return screenPos;
}
logger.info({ searchText, region, totalMs: elapsed(t) }, 'Text not found in region');
return null;
}
async readRegionText(region: Region): Promise<string> {
const result = await this.daemon.ocr(region);
return result.text;
}
async checkForText(region: Region, searchText: string): Promise<boolean> {
const pos = await this.findTextInRegion(region, searchText);
return pos !== null;
}
// ── Save utilities ──────────────────────────────────────────────────
async saveScreenshot(path: string): Promise<void> {
await this.daemon.saveScreenshot(path);
logger.info({ path }, 'Screenshot saved');
}
async saveDebugScreenshots(dir: string): Promise<string[]> {
await mkdir(dir, { recursive: true });
const ts = Date.now();
const originalPath = join(dir, `${ts}-screenshot.png`);
await this.daemon.saveScreenshot(originalPath);
logger.info({ dir, files: [originalPath.split(/[\\/]/).pop()] }, 'Debug screenshot saved');
return [originalPath];
}
async saveRegion(region: Region, path: string): Promise<void> {
await this.daemon.saveScreenshot(path, region);
logger.info({ path, region }, 'Region screenshot saved');
}
// ── Lifecycle ───────────────────────────────────────────────────────
async dispose(): Promise<void> {
await this.daemon.stop();
}
}

90
src/game/WindowManager.ts Normal file
View file

@ -0,0 +1,90 @@
import koffi from 'koffi';
import { logger } from '../util/logger.js';
// Win32 types
const HWND = 'int';
const BOOL = 'bool';
const RECT = koffi.struct('RECT', {
left: 'long',
top: 'long',
right: 'long',
bottom: 'long',
});
// Load user32.dll
const user32 = koffi.load('user32.dll');
const FindWindowW = user32.func('FindWindowW', HWND, ['str16', 'str16']);
const SetForegroundWindow = user32.func('SetForegroundWindow', BOOL, [HWND]);
const ShowWindow = user32.func('ShowWindow', BOOL, [HWND, 'int']);
const BringWindowToTop = user32.func('BringWindowToTop', BOOL, [HWND]);
const GetForegroundWindow = user32.func('GetForegroundWindow', HWND, []);
const GetWindowRect = user32.func('GetWindowRect', BOOL, [HWND, koffi.out(koffi.pointer(RECT))]);
const IsWindow = user32.func('IsWindow', BOOL, [HWND]);
const keybd_event = user32.func('keybd_event', 'void', ['uint8', 'uint8', 'uint32', 'uint']);
const MapVirtualKeyW = user32.func('MapVirtualKeyW', 'uint32', ['uint32', 'uint32']);
// Constants
const SW_RESTORE = 9;
const VK_MENU = 0x12; // Alt key
const KEYEVENTF_KEYUP = 0x0002;
export class WindowManager {
private hwnd: number = 0;
constructor(private windowTitle: string) {}
findWindow(): number {
this.hwnd = FindWindowW(null as unknown as string, this.windowTitle);
if (this.hwnd === 0) {
logger.warn({ title: this.windowTitle }, 'Window not found');
} else {
logger.info({ title: this.windowTitle, hwnd: this.hwnd }, 'Window found');
}
return this.hwnd;
}
focusWindow(): boolean {
if (!this.hwnd || !IsWindow(this.hwnd)) {
this.findWindow();
}
if (!this.hwnd) return false;
// Restore if minimized
ShowWindow(this.hwnd, SW_RESTORE);
// Alt-key trick to bypass SetForegroundWindow restriction
const altScan = MapVirtualKeyW(VK_MENU, 0);
keybd_event(VK_MENU, altScan, 0, 0);
keybd_event(VK_MENU, altScan, KEYEVENTF_KEYUP, 0);
BringWindowToTop(this.hwnd);
const result = SetForegroundWindow(this.hwnd);
if (!result) {
logger.warn('SetForegroundWindow failed');
}
return result;
}
getWindowRect(): { left: number; top: number; right: number; bottom: number } | null {
if (!this.hwnd || !IsWindow(this.hwnd)) {
this.findWindow();
}
if (!this.hwnd) return null;
const rect = { left: 0, top: 0, right: 0, bottom: 0 };
const success = GetWindowRect(this.hwnd, rect);
if (!success) return null;
return rect;
}
isGameFocused(): boolean {
const fg = GetForegroundWindow();
return fg === this.hwnd && this.hwnd !== 0;
}
getHwnd(): number {
return this.hwnd;
}
}

190
src/index.ts Normal file
View file

@ -0,0 +1,190 @@
import { Command } from 'commander';
import { loadConfig } from './config.js';
import { TradeMonitor } from './trade/TradeMonitor.js';
import { GameController } from './game/GameController.js';
import { ScreenReader } from './game/ScreenReader.js';
import { ClientLogWatcher } from './log/ClientLogWatcher.js';
import { TradeExecutor } from './executor/TradeExecutor.js';
import { TradeQueue } from './executor/TradeQueue.js';
import { BotController } from './dashboard/BotController.js';
import { DashboardServer } from './dashboard/DashboardServer.js';
import { ConfigStore } from './dashboard/ConfigStore.js';
import { logger } from './util/logger.js';
import type { Page } from 'playwright';
const program = new Command();
program
.name('poe2trade')
.description('POE2 automated trade bot')
.option('-u, --url <urls...>', 'Trade search URLs to monitor')
.option('--log-path <path>', 'Path to POE2 Client.txt')
.option('-p, --port <number>', 'Dashboard port')
.option('-c, --config <path>', 'Path to config.json', 'config.json')
.action(async (options) => {
// Load persisted config
const store = new ConfigStore(options.config);
const saved = store.settings;
// CLI/env overrides persisted values
const envConfig = loadConfig(options.url);
if (options.logPath) envConfig.poe2LogPath = options.logPath;
// Merge: CLI args > .env > config.json defaults
const config = {
...envConfig,
poe2LogPath: options.logPath || saved.poe2LogPath,
poe2WindowTitle: saved.poe2WindowTitle,
browserUserDataDir: saved.browserUserDataDir,
travelTimeoutMs: saved.travelTimeoutMs,
stashScanTimeoutMs: saved.stashScanTimeoutMs,
waitForMoreItemsMs: saved.waitForMoreItemsMs,
betweenTradesDelayMs: saved.betweenTradesDelayMs,
};
const port = parseInt(options.port, 10) || saved.dashboardPort;
// Collect all URLs: CLI args + saved links (deduped)
const allUrls = new Set<string>([
...config.tradeUrls,
...saved.links.map((l) => l.url),
]);
// Initialize bot controller with config store
const bot = new BotController(store);
// Start dashboard
const dashboard = new DashboardServer(bot, port);
await dashboard.start();
// Initialize game components
const screenReader = new ScreenReader();
const gameController = new GameController(config);
dashboard.setDebugDeps({ screenReader, gameController });
const logWatcher = new ClientLogWatcher(config.poe2LogPath);
await logWatcher.start();
dashboard.broadcastLog('info', 'Watching Client.txt for game events');
const tradeMonitor = new TradeMonitor(config);
await tradeMonitor.start(`http://localhost:${port}`);
dashboard.broadcastLog('info', 'Browser launched');
const executor = new TradeExecutor(
gameController,
screenReader,
logWatcher,
tradeMonitor,
config,
);
const tradeQueue = new TradeQueue(executor, config);
// Helper to add a trade search
const activateLink = async (url: string) => {
try {
await tradeMonitor.addSearch(url);
dashboard.broadcastLog('info', `Monitoring: ${url}`);
dashboard.broadcastStatus();
} catch (err) {
logger.error({ err, url }, 'Failed to add trade search');
dashboard.broadcastLog('error', `Failed to add: ${url}`);
}
};
// Load all saved + CLI links (only activate ones marked active)
for (const url of allUrls) {
const link = bot.addLink(url);
if (link.active) {
await activateLink(url);
} else {
dashboard.broadcastLog('info', `Loaded (inactive): ${link.name || link.label}`);
}
}
dashboard.broadcastLog('info', `Loaded ${allUrls.size} trade link(s) from config`);
// When dashboard adds a link, activate it in the trade monitor
bot.on('link-added', async (link) => {
if (link.active) {
await activateLink(link.url);
}
});
// When dashboard removes a link, deactivate it
bot.on('link-removed', async (id: string) => {
await tradeMonitor.removeSearch(id);
dashboard.broadcastLog('info', `Removed search: ${id}`);
dashboard.broadcastStatus();
});
// When dashboard toggles a link active/inactive
bot.on('link-toggled', async (data: { id: string; active: boolean; link: { url: string; name: string } }) => {
if (data.active) {
await activateLink(data.link.url);
dashboard.broadcastLog('info', `Activated: ${data.link.name || data.id}`);
} else {
await tradeMonitor.pauseSearch(data.id);
dashboard.broadcastLog('info', `Deactivated: ${data.link.name || data.id}`);
}
});
// Wire up events: when new listings appear, queue them for trading
tradeMonitor.on('new-listings', (data: { searchId: string; itemIds: string[]; page: Page }) => {
if (bot.isPaused) {
dashboard.broadcastLog('warn', `New listings (${data.itemIds.length}) skipped - bot paused`);
return;
}
// Check if this specific link is active
if (!bot.isLinkActive(data.searchId)) {
return;
}
logger.info(
{ searchId: data.searchId, itemCount: data.itemIds.length },
'New listings received, queuing trade...',
);
dashboard.broadcastLog('info', `New listings: ${data.itemIds.length} items from ${data.searchId}`);
tradeQueue.enqueue({
searchId: data.searchId,
itemIds: data.itemIds,
whisperText: '',
timestamp: Date.now(),
tradeUrl: '',
page: data.page,
});
});
// Forward executor state changes to dashboard
const stateInterval = setInterval(() => {
const execState = executor.getState();
if (bot.state !== execState) {
bot.state = execState;
dashboard.broadcastStatus();
}
}, 500);
// Graceful shutdown
const shutdown = async () => {
logger.info('Shutting down...');
clearInterval(stateInterval);
await screenReader.dispose();
await dashboard.stop();
await tradeMonitor.stop();
await logWatcher.stop();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
logger.info(`Dashboard: http://localhost:${port}`);
logger.info(
`Monitoring ${allUrls.size} trade search(es). Press Ctrl+C to stop.`,
);
});
program.parse();

130
src/log/ClientLogWatcher.ts Normal file
View file

@ -0,0 +1,130 @@
import { EventEmitter } from 'events';
import { watch } from 'chokidar';
import { createReadStream, statSync } from 'fs';
import { createInterface } from 'readline';
import { logger } from '../util/logger.js';
export interface LogEvents {
'area-entered': (area: string) => void;
'whisper-received': (data: { player: string; message: string }) => void;
'whisper-sent': (data: { player: string; message: string }) => void;
'trade-accepted': () => void;
'party-joined': (player: string) => void;
'party-left': (player: string) => void;
line: (line: string) => void;
}
export class ClientLogWatcher extends EventEmitter {
private watcher: ReturnType<typeof watch> | null = null;
private fileOffset: number = 0;
private logPath: string;
constructor(logPath: string) {
super();
this.logPath = logPath;
}
async start(): Promise<void> {
// Start reading from end of file (only new lines)
try {
const stats = statSync(this.logPath);
this.fileOffset = stats.size;
} catch {
logger.warn({ path: this.logPath }, 'Log file not found yet, will watch for creation');
this.fileOffset = 0;
}
this.watcher = watch(this.logPath, {
persistent: true,
usePolling: true,
interval: 200,
});
this.watcher.on('change', () => {
this.readNewLines();
});
logger.info({ path: this.logPath }, 'Watching Client.txt for game events');
}
private readNewLines(): void {
const stream = createReadStream(this.logPath, {
start: this.fileOffset,
encoding: 'utf-8',
});
const rl = createInterface({ input: stream });
let bytesRead = 0;
rl.on('line', (line) => {
bytesRead += Buffer.byteLength(line, 'utf-8') + 2; // +2 for \r\n on Windows
if (line.trim()) {
this.parseLine(line.trim());
}
});
rl.on('close', () => {
this.fileOffset += bytesRead;
});
}
private parseLine(line: string): void {
this.emit('line', line);
// Area transition: "You have entered Hideout"
const areaMatch = line.match(/You have entered (.+?)\.?$/);
if (areaMatch) {
const area = areaMatch[1];
logger.info({ area }, 'Area entered');
this.emit('area-entered', area);
return;
}
// Incoming whisper: "@From PlayerName: message"
const whisperFromMatch = line.match(/@From\s+(.+?):\s+(.+)$/);
if (whisperFromMatch) {
const data = { player: whisperFromMatch[1], message: whisperFromMatch[2] };
logger.info(data, 'Whisper received');
this.emit('whisper-received', data);
return;
}
// Outgoing whisper: "@To PlayerName: message"
const whisperToMatch = line.match(/@To\s+(.+?):\s+(.+)$/);
if (whisperToMatch) {
const data = { player: whisperToMatch[1], message: whisperToMatch[2] };
this.emit('whisper-sent', data);
return;
}
// Party join: "PlayerName has joined the party"
const partyJoinMatch = line.match(/(.+?) has joined the party/);
if (partyJoinMatch) {
logger.info({ player: partyJoinMatch[1] }, 'Player joined party');
this.emit('party-joined', partyJoinMatch[1]);
return;
}
// Party leave: "PlayerName has left the party"
const partyLeaveMatch = line.match(/(.+?) has left the party/);
if (partyLeaveMatch) {
this.emit('party-left', partyLeaveMatch[1]);
return;
}
// Trade accepted
if (line.includes('Trade accepted') || line.includes('Trade completed')) {
logger.info('Trade accepted/completed');
this.emit('trade-accepted');
return;
}
}
async stop(): Promise<void> {
if (this.watcher) {
await this.watcher.close();
this.watcher = null;
}
logger.info('Client log watcher stopped');
}
}

256
src/trade/TradeMonitor.ts Normal file
View file

@ -0,0 +1,256 @@
import { EventEmitter } from 'events';
import { chromium, type Browser, type BrowserContext, type Page, type WebSocket } from 'playwright';
import { SELECTORS } from './selectors.js';
import { logger } from '../util/logger.js';
import { sleep } from '../util/sleep.js';
import type { Config } from '../types.js';
// Stealth JS injected into every page to avoid Playwright detection
const STEALTH_SCRIPT = `
// Remove navigator.webdriver flag
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// Fake plugins array (empty = headless giveaway)
Object.defineProperty(navigator, 'plugins', {
get: () => [
{ name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer' },
{ name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai' },
{ name: 'Native Client', filename: 'internal-nacl-plugin' },
],
});
// Fake languages
Object.defineProperty(navigator, 'languages', {
get: () => ['en-US', 'en'],
});
// Remove Playwright/automation artifacts from window
delete window.__playwright;
delete window.__pw_manual;
// Fix chrome.runtime to look like a real browser
if (!window.chrome) window.chrome = {};
if (!window.chrome.runtime) window.chrome.runtime = { id: undefined };
// Prevent detection via permissions API
const originalQuery = window.navigator.permissions?.query;
if (originalQuery) {
window.navigator.permissions.query = (params) => {
if (params.name === 'notifications') {
return Promise.resolve({ state: Notification.permission });
}
return originalQuery(params);
};
}
`;
export class TradeMonitor extends EventEmitter {
private browser: Browser | null = null;
private context: BrowserContext | null = null;
private pages: Map<string, Page> = new Map();
private pausedSearches: Set<string> = new Set();
private config: Config;
constructor(config: Config) {
super();
this.config = config;
}
async start(dashboardUrl?: string): Promise<void> {
logger.info('Launching Playwright browser (stealth mode)...');
this.context = await chromium.launchPersistentContext(this.config.browserUserDataDir, {
headless: false,
viewport: null,
args: [
'--disable-blink-features=AutomationControlled',
'--disable-features=AutomationControlled',
'--no-first-run',
'--no-default-browser-check',
'--disable-infobars',
],
ignoreDefaultArgs: ['--enable-automation'],
});
// Inject stealth script into all pages (current and future)
await this.context.addInitScript(STEALTH_SCRIPT);
// Open dashboard as the first tab
if (dashboardUrl) {
const pages = this.context.pages();
if (pages.length > 0) {
await pages[0].goto(dashboardUrl);
} else {
const page = await this.context.newPage();
await page.goto(dashboardUrl);
}
logger.info({ dashboardUrl }, 'Dashboard opened in browser');
}
logger.info('Browser launched (stealth active).');
}
async addSearch(tradeUrl: string): Promise<void> {
if (!this.context) throw new Error('Browser not started');
const searchId = this.extractSearchId(tradeUrl);
// Don't add duplicate
if (this.pages.has(searchId)) {
logger.info({ searchId }, 'Search already open, skipping');
return;
}
logger.info({ tradeUrl, searchId }, 'Adding trade search');
const page = await this.context.newPage();
this.pages.set(searchId, page);
await page.goto(tradeUrl, { waitUntil: 'networkidle' });
await sleep(2000);
// Listen for WebSocket connections (must be registered before clicking live search)
page.on('websocket', (ws: WebSocket) => {
this.handleWebSocket(ws, searchId, page);
});
// Click the "Activate Live Search" button
try {
const liveBtn = page.locator(SELECTORS.liveSearchButton).first();
await liveBtn.click({ timeout: 5000 });
logger.info({ searchId }, 'Live search activated');
} catch {
logger.warn({ searchId }, 'Could not click Activate Live Search button');
}
logger.info({ searchId }, 'Trade search monitoring active');
}
async pauseSearch(searchId: string): Promise<void> {
this.pausedSearches.add(searchId);
// Close the page to stop the WebSocket / live search
const page = this.pages.get(searchId);
if (page) {
await page.close();
this.pages.delete(searchId);
}
logger.info({ searchId }, 'Search paused (page closed)');
}
async resumeSearch(tradeUrl: string): Promise<void> {
const searchId = this.extractSearchId(tradeUrl);
this.pausedSearches.delete(searchId);
await this.addSearch(tradeUrl);
logger.info({ searchId }, 'Search resumed');
}
isSearchActive(searchId: string): boolean {
return this.pages.has(searchId) && !this.pausedSearches.has(searchId);
}
private handleWebSocket(ws: WebSocket, searchId: string, page: Page): void {
const url = ws.url();
if (!url.includes('/api/trade') || !url.includes('/live/')) {
return;
}
logger.info({ url, searchId }, 'WebSocket connected for live search');
ws.on('framereceived', (frame) => {
// Don't emit if this search is paused
if (this.pausedSearches.has(searchId)) return;
try {
const payload = typeof frame.payload === 'string' ? frame.payload : frame.payload.toString();
const data = JSON.parse(payload);
if (data.new && Array.isArray(data.new) && data.new.length > 0) {
logger.info({ searchId, itemCount: data.new.length, itemIds: data.new }, 'New listings detected!');
this.emit('new-listings', {
searchId,
itemIds: data.new as string[],
page,
});
}
} catch {
// Not all frames are JSON
}
});
ws.on('close', () => {
logger.warn({ searchId }, 'WebSocket closed');
});
ws.on('socketerror', (err) => {
logger.error({ searchId, err }, 'WebSocket error');
});
}
async clickTravelToHideout(page: Page, itemId?: string): Promise<boolean> {
try {
if (itemId) {
const row = page.locator(SELECTORS.listingById(itemId));
if (await row.isVisible({ timeout: 5000 })) {
const travelBtn = row.locator(SELECTORS.travelToHideoutButton).first();
if (await travelBtn.isVisible({ timeout: 3000 })) {
await travelBtn.click();
logger.info({ itemId }, 'Clicked Travel to Hideout for specific item');
await this.handleConfirmDialog(page);
return true;
}
}
}
const travelBtn = page.locator(SELECTORS.travelToHideoutButton).first();
await travelBtn.click({ timeout: 5000 });
logger.info('Clicked Travel to Hideout');
await this.handleConfirmDialog(page);
return true;
} catch (err) {
logger.error({ err }, 'Failed to click Travel to Hideout');
return false;
}
}
private async handleConfirmDialog(page: Page): Promise<void> {
await sleep(500);
try {
const confirmBtn = page.locator(SELECTORS.confirmYesButton).first();
if (await confirmBtn.isVisible({ timeout: 2000 })) {
await confirmBtn.click();
logger.info('Confirmed "Are you sure?" dialog');
}
} catch {
// No dialog
}
}
extractSearchId(url: string): string {
const cleaned = url.replace(/\/live\/?$/, '');
const parts = cleaned.split('/');
return parts[parts.length - 1] || url;
}
async removeSearch(searchId: string): Promise<void> {
this.pausedSearches.delete(searchId);
const page = this.pages.get(searchId);
if (page) {
await page.close();
this.pages.delete(searchId);
logger.info({ searchId }, 'Trade search removed');
}
}
async stop(): Promise<void> {
for (const [id, page] of this.pages) {
await page.close();
this.pages.delete(id);
}
if (this.context) {
await this.context.close();
this.context = null;
}
logger.info('Trade monitor stopped');
}
}

30
src/trade/selectors.ts Normal file
View file

@ -0,0 +1,30 @@
// CSS selectors for the POE2 trade website (pathofexile.com/trade2)
// These need to be verified against the live site and updated if the site changes.
export const SELECTORS = {
// Live search activation button
liveSearchButton: 'button.livesearch-btn, button:has-text("Activate Live Search")',
// Individual listing rows
listingRow: '.resultset .row, [class*="result"]',
// Listing by item ID
listingById: (id: string) => `[data-id="${id}"]`,
// "Travel to Hideout" / "Visit Hideout" button on a listing
travelToHideoutButton:
'button:has-text("Travel to Hideout"), button:has-text("Visit Hideout"), a:has-text("Travel to Hideout"), [class*="hideout"]',
// Whisper / copy button on a listing
whisperButton:
'.whisper-btn, button[class*="whisper"], [data-tooltip="Whisper"], button:has-text("Whisper")',
// "Are you sure?" confirmation dialog
confirmDialog: '[class*="modal"], [class*="dialog"], [class*="confirm"]',
confirmYesButton:
'button:has-text("Yes"), button:has-text("Confirm"), button:has-text("OK"), button:has-text("Accept")',
confirmNoButton: 'button:has-text("No"), button:has-text("Cancel"), button:has-text("Decline")',
// Search results container
resultsContainer: '.resultset, [class*="results"]',
} as const;

58
src/types.ts Normal file
View file

@ -0,0 +1,58 @@
export interface Config {
tradeUrls: string[];
poe2LogPath: string;
poe2WindowTitle: string;
browserUserDataDir: string;
travelTimeoutMs: number;
stashScanTimeoutMs: number;
waitForMoreItemsMs: number;
betweenTradesDelayMs: number;
}
export interface Region {
x: number;
y: number;
width: number;
height: number;
}
export interface ScreenRegions {
stashArea: Region;
priceWarningDialog: Region;
priceWarningNoButton: Region;
inventoryArea: Region;
stashTabArea: Region;
}
export interface TradeInfo {
searchId: string;
itemIds: string[];
whisperText: string;
timestamp: number;
tradeUrl: string;
page: unknown; // Playwright Page reference
}
export interface StashItem {
name: string;
stats: string;
price: string;
position: { x: number; y: number };
}
export type TradeState =
| 'IDLE'
| 'TRAVELING'
| 'IN_SELLERS_HIDEOUT'
| 'SCANNING_STASH'
| 'BUYING'
| 'WAITING_FOR_MORE'
| 'GOING_HOME'
| 'IN_HIDEOUT'
| 'FAILED';
export interface LogEvent {
timestamp: Date;
type: 'area-entered' | 'whisper-received' | 'trade-accepted' | 'unknown';
data: Record<string, string>;
}

13
src/util/clipboard.ts Normal file
View file

@ -0,0 +1,13 @@
import { execSync } from 'child_process';
export function readClipboard(): string {
try {
return execSync('powershell -command "Get-Clipboard"', { encoding: 'utf-8' }).trim();
} catch {
return '';
}
}
export function writeClipboard(text: string): void {
execSync(`powershell -command "Set-Clipboard -Value '${text.replace(/'/g, "''")}'"`);
}

12
src/util/logger.ts Normal file
View file

@ -0,0 +1,12 @@
import pino from 'pino';
export const logger = pino({
transport: {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss',
ignore: 'pid,hostname',
},
},
});

24
src/util/retry.ts Normal file
View file

@ -0,0 +1,24 @@
import { sleep } from './sleep.js';
import { logger } from './logger.js';
export async function retry<T>(
fn: () => Promise<T>,
options: { maxAttempts?: number; delayMs?: number; label?: string } = {},
): Promise<T> {
const { maxAttempts = 3, delayMs = 1000, label = 'operation' } = options;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
if (attempt === maxAttempts) {
logger.error({ err, attempt, label }, `${label} failed after ${maxAttempts} attempts`);
throw err;
}
logger.warn({ err, attempt, label }, `${label} failed, retrying in ${delayMs}ms...`);
await sleep(delayMs * attempt);
}
}
throw new Error('Unreachable');
}

8
src/util/sleep.ts Normal file
View file

@ -0,0 +1,8 @@
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
const delay = minMs + Math.random() * (maxMs - minMs);
return sleep(delay);
}

View file

@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows10.0.19041.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Drawing.Common" Version="8.0.12" />
</ItemGroup>
</Project>

293
tools/OcrDaemon/Program.cs Normal file
View file

@ -0,0 +1,293 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;
using Windows.Storage.Streams;
// Make GDI capture DPI-aware so coordinates match physical pixels
SetProcessDPIAware();
// Pre-create the OCR engine (reused across all requests)
var ocrEngine = OcrEngine.TryCreateFromUserProfileLanguages();
if (ocrEngine == null)
{
WriteResponse(new ErrorResponse("Failed to create OCR engine. Ensure a language pack is installed."));
return 1;
}
// Signal ready
WriteResponse(new ReadyResponse());
// JSON options
var jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
// Main loop: read one JSON line, handle, write one JSON line
var stdin = Console.In;
string? line;
while ((line = stdin.ReadLine()) != null)
{
line = line.Trim();
if (line.Length == 0) continue;
try
{
var request = JsonSerializer.Deserialize<Request>(line, jsonOptions);
if (request == null)
{
WriteResponse(new ErrorResponse("Failed to parse request"));
continue;
}
switch (request.Cmd?.ToLowerInvariant())
{
case "ocr":
HandleOcr(request, ocrEngine);
break;
case "screenshot":
HandleScreenshot(request);
break;
case "capture":
HandleCapture(request);
break;
default:
WriteResponse(new ErrorResponse($"Unknown command: {request.Cmd}"));
break;
}
}
catch (Exception ex)
{
WriteResponse(new ErrorResponse(ex.Message));
}
}
return 0;
// ── Handlers ────────────────────────────────────────────────────────────────
void HandleOcr(Request req, OcrEngine engine)
{
using var bitmap = CaptureScreen(req.Region);
var softwareBitmap = BitmapToSoftwareBitmap(bitmap);
var result = engine.RecognizeAsync(softwareBitmap).AsTask().GetAwaiter().GetResult();
var lines = new List<OcrLineResult>();
foreach (var ocrLine in result.Lines)
{
var words = new List<OcrWordResult>();
foreach (var word in ocrLine.Words)
{
words.Add(new OcrWordResult
{
Text = word.Text,
X = (int)Math.Round(word.BoundingRect.X),
Y = (int)Math.Round(word.BoundingRect.Y),
Width = (int)Math.Round(word.BoundingRect.Width),
Height = (int)Math.Round(word.BoundingRect.Height),
});
}
lines.Add(new OcrLineResult { Text = ocrLine.Text, Words = words });
}
WriteResponse(new OcrResponse { Text = result.Text, Lines = lines });
}
void HandleScreenshot(Request req)
{
if (string.IsNullOrEmpty(req.Path))
{
WriteResponse(new ErrorResponse("screenshot command requires 'path'"));
return;
}
using var bitmap = CaptureScreen(req.Region);
var format = GetImageFormat(req.Path);
bitmap.Save(req.Path, format);
WriteResponse(new OkResponse());
}
void HandleCapture(Request req)
{
using var bitmap = CaptureScreen(req.Region);
using var ms = new MemoryStream();
bitmap.Save(ms, ImageFormat.Png);
var base64 = Convert.ToBase64String(ms.ToArray());
WriteResponse(new CaptureResponse { Image = base64 });
}
// ── Screen Capture ──────────────────────────────────────────────────────────
Bitmap CaptureScreen(RegionRect? region)
{
int x, y, w, h;
if (region != null)
{
x = region.X;
y = region.Y;
w = region.Width;
h = region.Height;
}
else
{
// Primary monitor only (0,0 origin, SM_CXSCREEN / SM_CYSCREEN)
x = 0;
y = 0;
w = GetSystemMetrics(0); // SM_CXSCREEN
h = GetSystemMetrics(1); // SM_CYSCREEN
}
var bitmap = new Bitmap(w, h, PixelFormat.Format32bppArgb);
using var g = Graphics.FromImage(bitmap);
g.CopyFromScreen(x, y, 0, 0, new Size(w, h), CopyPixelOperation.SourceCopy);
return bitmap;
}
// ── Bitmap → SoftwareBitmap conversion (in-memory) ─────────────────────────
SoftwareBitmap BitmapToSoftwareBitmap(Bitmap bitmap)
{
using var ms = new MemoryStream();
bitmap.Save(ms, ImageFormat.Bmp);
ms.Position = 0;
var stream = ms.AsRandomAccessStream();
var decoder = BitmapDecoder.CreateAsync(stream).AsTask().GetAwaiter().GetResult();
var softwareBitmap = decoder.GetSoftwareBitmapAsync().AsTask().GetAwaiter().GetResult();
return softwareBitmap;
}
// ── Response writing ────────────────────────────────────────────────────────
void WriteResponse(object response)
{
var json = JsonSerializer.Serialize(response, jsonOptions);
Console.Out.WriteLine(json);
Console.Out.Flush();
}
ImageFormat GetImageFormat(string path)
{
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch
{
".jpg" or ".jpeg" => ImageFormat.Jpeg,
".bmp" => ImageFormat.Bmp,
_ => ImageFormat.Png,
};
}
// ── P/Invoke ────────────────────────────────────────────────────────────────
[DllImport("user32.dll")]
static extern bool SetProcessDPIAware();
[DllImport("user32.dll")]
static extern int GetSystemMetrics(int nIndex);
// ── Request / Response Models ───────────────────────────────────────────────
class Request
{
[JsonPropertyName("cmd")]
public string? Cmd { get; set; }
[JsonPropertyName("region")]
public RegionRect? Region { get; set; }
[JsonPropertyName("path")]
public string? Path { get; set; }
}
class RegionRect
{
[JsonPropertyName("x")]
public int X { get; set; }
[JsonPropertyName("y")]
public int Y { get; set; }
[JsonPropertyName("width")]
public int Width { get; set; }
[JsonPropertyName("height")]
public int Height { get; set; }
}
class ReadyResponse
{
[JsonPropertyName("ok")]
public bool Ok => true;
[JsonPropertyName("ready")]
public bool Ready => true;
}
class OkResponse
{
[JsonPropertyName("ok")]
public bool Ok => true;
}
class ErrorResponse(string message)
{
[JsonPropertyName("ok")]
public bool Ok => false;
[JsonPropertyName("error")]
public string Error => message;
}
class OcrResponse
{
[JsonPropertyName("ok")]
public bool Ok => true;
[JsonPropertyName("text")]
public string Text { get; set; } = "";
[JsonPropertyName("lines")]
public List<OcrLineResult> Lines { get; set; } = [];
}
class OcrLineResult
{
[JsonPropertyName("text")]
public string Text { get; set; } = "";
[JsonPropertyName("words")]
public List<OcrWordResult> Words { get; set; } = [];
}
class OcrWordResult
{
[JsonPropertyName("text")]
public string Text { get; set; } = "";
[JsonPropertyName("x")]
public int X { get; set; }
[JsonPropertyName("y")]
public int Y { get; set; }
[JsonPropertyName("width")]
public int Width { get; set; }
[JsonPropertyName("height")]
public int Height { get; set; }
}
class CaptureResponse
{
[JsonPropertyName("ok")]
public bool Ok => true;
[JsonPropertyName("image")]
public string Image { get; set; } = "";
}

19
tsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}