using System.Collections.Concurrent; using System.Text.Json; using FinlyticAssets.Models.DataToObject.TradeRepublic; namespace FinlyticAssets.Util; /// /// A managed WebSocket client designed to communicate with the Trade Republic API. /// Handles asynchronous requests, generic serialization, and automatic subscription management. /// public class TradeRepublicClient : ManagedWebSocket { private readonly ILogger _logger; private int _currentSub; private readonly ConcurrentDictionary> _pendingRequests = new(); /// /// Wird ausgelöst, wenn Trade Republic asynchrone Updates (z.B. Live-Preise) schickt, /// auf die niemand aktiv per SendRequestAsync wartet. /// public event Action? UnhandledMessageReceived; /// /// Wird ausgelöst, wenn Trade Republic Systemnachrichten oder Fehler ohne ID schickt. /// public event Action? SystemMessageReceived; /// /// Initializes a new instance of the TradeRepublicClient. /// Call InitAsync() afterwards to establish the connection. /// /// The logger instance for tracking socket events and errors. public TradeRepublicClient(ILogger logger) { _logger = logger; } /// /// Asynchronously establishes the WebSocket connection to the Trade Republic API. /// public async Task InitAsync() { await ConnectAsync("wss://api.traderepublic.com/", TimeSpan.FromSeconds(10)); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(-1, tcs); try { var json = JsonSerializer.Serialize(new TradeRepublicConnectRequest()); await SendAsync($"connect 34 {json}"); var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); if (string.IsNullOrWhiteSpace(res.Type)) return false; var isConnected = res.Type == "connected"; if (isConnected) { _logger.LogInformation("WebSocket connection to Trade Republic established."); } return isConnected; } catch (TimeoutException) { _pendingRequests.TryRemove(-1, out _); _logger.LogWarning("Timeout while waiting for response to ID {Id}.", -1); return false; } catch (TaskCanceledException) { _logger.LogWarning( "Trade Republic immediately rejected the request for ID {Id} (e.g., invalid ISIN or access denied).", -1); return false; } } /// /// Sends a strongly-typed request to the API and waits for the corresponding response. /// Automatically handles the subscription ID and unsubscribes after completion or failure. /// /// The expected type of the response payload. /// The type of the request payload. /// The request data to be serialized and sent. /// The deserialized response object, or null if the request timed out or was canceled. public async Task SendRequestAsync(TRequest request) where TResponse : class where TRequest : class { var tempSub = Interlocked.Increment(ref _currentSub); var msg = $"sub {tempSub} {JsonSerializer.Serialize(request)}"; Console.WriteLine(msg); var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests.TryAdd(tempSub, tcs); await SendAsync(msg); try { var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5)); if (res.Type == null) { _logger.LogWarning("Trade Republic rejected the request for ID {Id} with message type '{Type}'.", tempSub, res.Type); return null; } if (!res.Type.Contains('A')) { return null; } if (string.IsNullOrWhiteSpace(res.Data)) return null; if (typeof(TResponse) == typeof(string)) { return res.Data as TResponse; } else { return JsonSerializer.Deserialize(res.Data); } } catch (TimeoutException) { _pendingRequests.TryRemove(tempSub, out _); _logger.LogWarning("Timeout while waiting for response to ID {Id}.", tempSub); return null; } catch (TaskCanceledException) { _logger.LogWarning( "Trade Republic immediately rejected the request for ID {Id} (e.g., invalid ISIN or access denied).", tempSub); return null; } finally { if (IsConnected) { try { await SendAsync($"unsub {tempSub}"); } catch { //ignore } } } } /// /// Processes incoming WebSocket messages, extracting the JSON payload and resolving pending tasks. /// /// The raw text message received from the server. protected override void OnMessageReceived(string message) { if (string.IsNullOrWhiteSpace(message)) return; // 1. Handshake-Nachricht direkt abfangen if (message == "connected") { if (_pendingRequests.TryRemove(-1, out var tcs)) { tcs.SetResult(new ReceivedMessage(-1, "connected", null)); } return; } // 2. Erstes Leerzeichen finden, um die ID zu isolieren var firstSpaceIndex = message.IndexOf(' '); if (firstSpaceIndex <= 0) { _logger.LogWarning("Unknown message format received: {Message}", message); SystemMessageReceived?.Invoke(message); return; } var idString = message[..firstSpaceIndex]; if (!int.TryParse(idString, out var responseId)) { SystemMessageReceived?.Invoke(message); return; } // Der Rest nach der ID (z. B. "A {...}" oder "C") var remainder = message[firstSpaceIndex..].Trim(); // 3. Nachrichtentyp ("A", "C", etc.) und JSON-Inhalt sauber trennen var nextSpaceIndex = remainder.IndexOf(' '); string msgType; string? json = null; if (nextSpaceIndex == -1) { // Kein weiteres Leerzeichen vorhanden (wie bei "2 C") msgType = remainder; } else { // Typ und JSON trennen (wie bei "2 A {...}") msgType = remainder[..nextSpaceIndex].Trim(); json = remainder[nextSpaceIndex..].Trim(); } // 4. KORREKTUR: "C" signalisiert nur das Ende des Datenstroms auf dieser ID. // Wir ignorieren es, da die Daten bereits im Typ "A" übertragen wurden. if (msgType == "C") { _logger.LogDebug("Trade Republic closed subscription channel for ID {ResponseId}.", responseId); return; } // 5. Task auflösen, falls jemand auf diese ID wartet if (_pendingRequests.TryRemove(responseId, out var pendingTcs)) { pendingTcs.SetResult(new ReceivedMessage(responseId, msgType, json)); } else { UnhandledMessageReceived?.Invoke(new ReceivedMessage(responseId, msgType, json)); } } /// /// Determines whether the incoming message is a keep-alive echo response. /// /// The raw text message. /// True if the message is an echo response; otherwise, false. protected override bool IsKeepAliveMessage(string message) { return message.StartsWith("echo"); } /// /// Sends a periodic keep-alive echo to maintain the WebSocket connection. /// protected override Task SendLifeMessageAsync() { var echo = $"echo {DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; return SendAsync(echo); } } public record ReceivedMessage(int? Sub, string? Type, string? Data);