337 lines
11 KiB
C#
337 lines
11 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Diagnostics;
|
|
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
using Poe2Trade.Core;
|
|
using Serilog;
|
|
|
|
namespace Poe2Trade.Trade;
|
|
|
|
public class TradeDaemonBridge : ITradeMonitor
|
|
{
|
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
|
{
|
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
|
};
|
|
|
|
private Process? _proc;
|
|
private int _reqCounter;
|
|
private readonly ConcurrentDictionary<int, TaskCompletionSource<JsonElement>> _pending = new();
|
|
private readonly SavedSettings _config;
|
|
private readonly string _daemonScript;
|
|
private readonly string _nodeExe;
|
|
|
|
public event Action<string, List<string>>? NewListings;
|
|
|
|
public TradeDaemonBridge(SavedSettings config)
|
|
{
|
|
_config = config;
|
|
_daemonScript = Path.GetFullPath(Path.Combine("tools", "trade-daemon", "daemon.mjs"));
|
|
_nodeExe = "node";
|
|
}
|
|
|
|
public async Task Start(string? dashboardUrl = null)
|
|
{
|
|
EnsureDaemonRunning();
|
|
|
|
var userDataDir = Path.GetFullPath(_config.BrowserUserDataDir);
|
|
await SendCommand("start", new
|
|
{
|
|
browserUserDataDir = userDataDir,
|
|
headless = _config.Headless,
|
|
dashboardUrl,
|
|
});
|
|
|
|
Log.Information("Trade daemon browser started");
|
|
}
|
|
|
|
public async Task AddSearch(string tradeUrl)
|
|
{
|
|
EnsureDaemonRunning();
|
|
await SendCommand("addSearch", new { url = tradeUrl });
|
|
}
|
|
|
|
public async Task PauseSearch(string searchId)
|
|
{
|
|
EnsureDaemonRunning();
|
|
await SendCommand("pauseSearch", new { searchId });
|
|
}
|
|
|
|
public async Task<bool> ClickTravelToHideout(string pageId, string? itemId = null)
|
|
{
|
|
EnsureDaemonRunning();
|
|
var resp = await SendCommand("clickTravel", new { pageId, itemId });
|
|
return resp.TryGetProperty("clicked", out var c) && c.GetBoolean();
|
|
}
|
|
|
|
public async Task<(string ScrapId, List<TradeItem> Items)> OpenScrapPage(string tradeUrl)
|
|
{
|
|
EnsureDaemonRunning();
|
|
var resp = await SendCommand("openScrapPage", new { url = tradeUrl });
|
|
var scrapId = resp.GetProperty("scrapId").GetString()!;
|
|
var items = ParseItems(resp);
|
|
return (scrapId, items);
|
|
}
|
|
|
|
public async Task<List<TradeItem>> ReloadScrapPage(string scrapId)
|
|
{
|
|
EnsureDaemonRunning();
|
|
var resp = await SendCommand("reloadScrapPage", new { scrapId });
|
|
return ParseItems(resp);
|
|
}
|
|
|
|
public async Task CloseScrapPage(string scrapId)
|
|
{
|
|
EnsureDaemonRunning();
|
|
await SendCommand("closeScrapPage", new { scrapId });
|
|
}
|
|
|
|
public string ExtractSearchId(string url)
|
|
{
|
|
var cleaned = System.Text.RegularExpressions.Regex.Replace(url, @"/live/?$", "");
|
|
var parts = cleaned.Split('/');
|
|
return parts.Length > 0 ? parts[^1] : url;
|
|
}
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (_proc != null && !_proc.HasExited)
|
|
{
|
|
try
|
|
{
|
|
// Send stop command (best effort)
|
|
var reqId = Interlocked.Increment(ref _reqCounter);
|
|
var msg = JsonSerializer.Serialize(new { reqId, cmd = "stop" }, JsonOpts);
|
|
await _proc.StandardInput.WriteLineAsync(msg);
|
|
await _proc.StandardInput.FlushAsync();
|
|
_proc.WaitForExit(5000);
|
|
}
|
|
catch { /* ignore */ }
|
|
|
|
if (_proc != null && !_proc.HasExited)
|
|
{
|
|
try { _proc.Kill(); } catch { /* ignore */ }
|
|
}
|
|
}
|
|
|
|
_proc?.Dispose();
|
|
_proc = null;
|
|
|
|
// Complete any pending requests
|
|
foreach (var kv in _pending)
|
|
{
|
|
kv.Value.TrySetCanceled();
|
|
_pending.TryRemove(kv.Key, out _);
|
|
}
|
|
|
|
Log.Information("Trade daemon stopped");
|
|
}
|
|
|
|
private async Task<JsonElement> SendCommand(string cmd, object? parameters = null)
|
|
{
|
|
if (_proc == null || _proc.HasExited)
|
|
throw new InvalidOperationException("Trade daemon is not running");
|
|
|
|
var reqId = Interlocked.Increment(ref _reqCounter);
|
|
var tcs = new TaskCompletionSource<JsonElement>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
_pending[reqId] = tcs;
|
|
|
|
// Build command object: merge reqId + cmd + params
|
|
var dict = new Dictionary<string, object?> { ["reqId"] = reqId, ["cmd"] = cmd };
|
|
if (parameters != null)
|
|
{
|
|
var paramJson = JsonSerializer.SerializeToElement(parameters, JsonOpts);
|
|
foreach (var prop in paramJson.EnumerateObject())
|
|
dict[prop.Name] = prop.Value;
|
|
}
|
|
|
|
var json = JsonSerializer.Serialize(dict, JsonOpts);
|
|
await _proc.StandardInput.WriteLineAsync(json);
|
|
await _proc.StandardInput.FlushAsync();
|
|
|
|
// Await response with timeout
|
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
|
|
cts.Token.Register(() => tcs.TrySetCanceled());
|
|
|
|
try
|
|
{
|
|
return await tcs.Task;
|
|
}
|
|
finally
|
|
{
|
|
_pending.TryRemove(reqId, out _);
|
|
}
|
|
}
|
|
|
|
private void EnsureDaemonRunning()
|
|
{
|
|
if (_proc != null && !_proc.HasExited)
|
|
return;
|
|
|
|
_proc?.Dispose();
|
|
_proc = null;
|
|
|
|
if (!File.Exists(_daemonScript))
|
|
throw new FileNotFoundException($"Trade daemon not found at {_daemonScript}");
|
|
|
|
Log.Information("Spawning trade daemon: {Node} {Script}", _nodeExe, _daemonScript);
|
|
|
|
var proc = new Process
|
|
{
|
|
StartInfo = new ProcessStartInfo
|
|
{
|
|
FileName = _nodeExe,
|
|
Arguments = $"\"{_daemonScript}\"",
|
|
UseShellExecute = false,
|
|
RedirectStandardInput = true,
|
|
RedirectStandardOutput = true,
|
|
RedirectStandardError = true,
|
|
CreateNoWindow = true,
|
|
}
|
|
};
|
|
|
|
proc.ErrorDataReceived += (_, e) =>
|
|
{
|
|
if (!string.IsNullOrEmpty(e.Data))
|
|
Log.Debug("[trade-daemon] {Line}", e.Data);
|
|
};
|
|
|
|
try
|
|
{
|
|
proc.Start();
|
|
proc.BeginErrorReadLine();
|
|
|
|
// Wait for ready signal (up to 15s)
|
|
var readyTask = Task.Run(() => proc.StandardOutput.ReadLine());
|
|
if (!readyTask.Wait(TimeSpan.FromSeconds(15)))
|
|
throw new TimeoutException("Trade daemon did not send ready signal within 15s");
|
|
|
|
var readyLine = readyTask.Result
|
|
?? throw new Exception("Trade daemon exited before ready signal");
|
|
|
|
var readyDoc = JsonDocument.Parse(readyLine);
|
|
if (!readyDoc.RootElement.TryGetProperty("type", out var typeProp) ||
|
|
typeProp.GetString() != "ready")
|
|
throw new Exception($"Trade daemon did not send ready signal: {readyLine}");
|
|
}
|
|
catch
|
|
{
|
|
try { if (!proc.HasExited) proc.Kill(); } catch { /* best effort */ }
|
|
proc.Dispose();
|
|
throw;
|
|
}
|
|
|
|
_proc = proc;
|
|
|
|
// Start background reader thread
|
|
_ = Task.Run(() => ReadLoop(proc));
|
|
|
|
Log.Information("Trade daemon ready");
|
|
}
|
|
|
|
private void ReadLoop(Process proc)
|
|
{
|
|
try
|
|
{
|
|
while (!proc.HasExited)
|
|
{
|
|
var line = proc.StandardOutput.ReadLine();
|
|
if (line == null) break;
|
|
|
|
try
|
|
{
|
|
using var doc = JsonDocument.Parse(line);
|
|
var root = doc.RootElement;
|
|
var type = root.GetProperty("type").GetString();
|
|
|
|
if (type == "response")
|
|
{
|
|
var reqId = root.GetProperty("reqId").GetInt32();
|
|
if (_pending.TryGetValue(reqId, out var tcs))
|
|
{
|
|
var ok = root.GetProperty("ok").GetBoolean();
|
|
if (ok)
|
|
tcs.TrySetResult(root.Clone());
|
|
else
|
|
{
|
|
var error = root.TryGetProperty("error", out var e)
|
|
? e.GetString() ?? "Unknown error"
|
|
: "Unknown error";
|
|
tcs.TrySetException(new Exception($"Trade daemon error: {error}"));
|
|
}
|
|
}
|
|
}
|
|
else if (type == "event")
|
|
{
|
|
HandleEvent(root);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Debug("Failed to parse daemon output: {Line} - {Error}", line, ex.Message);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Warning(ex, "Trade daemon read loop ended");
|
|
}
|
|
|
|
// Daemon exited — fail all pending requests
|
|
foreach (var kv in _pending)
|
|
{
|
|
kv.Value.TrySetException(new Exception("Trade daemon process exited"));
|
|
_pending.TryRemove(kv.Key, out _);
|
|
}
|
|
}
|
|
|
|
private void HandleEvent(JsonElement root)
|
|
{
|
|
var eventName = root.GetProperty("event").GetString();
|
|
switch (eventName)
|
|
{
|
|
case "newListings":
|
|
var searchId = root.GetProperty("searchId").GetString()!;
|
|
var itemIds = root.GetProperty("itemIds").EnumerateArray()
|
|
.Select(e => e.GetString()!)
|
|
.Where(s => s != null)
|
|
.ToList();
|
|
if (itemIds.Count > 0)
|
|
{
|
|
Log.Information("New listings from daemon: {SearchId} ({Count} items)", searchId, itemIds.Count);
|
|
NewListings?.Invoke(searchId, itemIds);
|
|
}
|
|
break;
|
|
|
|
case "wsClose":
|
|
var closedId = root.GetProperty("searchId").GetString()!;
|
|
Log.Warning("WebSocket closed (daemon): {SearchId}", closedId);
|
|
break;
|
|
|
|
default:
|
|
Log.Debug("Unknown daemon event: {Event}", eventName);
|
|
break;
|
|
}
|
|
}
|
|
|
|
private static List<TradeItem> ParseItems(JsonElement resp)
|
|
{
|
|
var items = new List<TradeItem>();
|
|
if (resp.TryGetProperty("items", out var arr) && arr.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var el in arr.EnumerateArray())
|
|
{
|
|
items.Add(new TradeItem(
|
|
el.GetProperty("id").GetString() ?? "",
|
|
el.TryGetProperty("w", out var w) ? w.GetInt32() : 1,
|
|
el.TryGetProperty("h", out var h) ? h.GetInt32() : 1,
|
|
el.TryGetProperty("stashX", out var sx) ? sx.GetInt32() : 0,
|
|
el.TryGetProperty("stashY", out var sy) ? sy.GetInt32() : 0,
|
|
el.TryGetProperty("account", out var acc) ? acc.GetString() ?? "" : ""
|
|
));
|
|
}
|
|
}
|
|
return items;
|
|
}
|
|
}
|