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

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');
}
}