Files
Finlytic/FinlyticBackend/Util/BackendMqttBridge.cs
T

315 lines
14 KiB
C#

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Services;
using FinlyticBackend.Settings;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Logging;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Util;
/// <summary>
/// Central Managed MQTT Bridge & RPC Gateway for FinlyticBackend.
/// Subscribes to general broadcast MQTT topics and forwards them to SignalR clients and FCM push services.
/// </summary>
public class BackendMqttBridge : ManagedMqttClient, IHostedService
{
public static readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessageDto>> ServiceLogsRingBuffer = new(StringComparer.OrdinalIgnoreCase);
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IHubContext<TradeStreamHub, ITradeStreamClient> _tradeStreamHubContext;
private readonly IHubContext<NewsHub> _newsHubContext;
private readonly IHubContext<LogStreamHub> _logHubContext;
private readonly IFirebaseNotificationService _firebaseService;
private readonly ILogger<BackendMqttBridge> _logger;
private readonly IFinlyticLogger<BackendMqttBridge> _finlyticLogger;
public BackendMqttBridge(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
IHubContext<TradeStreamHub, ITradeStreamClient> tradeStreamHubContext,
IHubContext<NewsHub> newsHubContext,
IHubContext<LogStreamHub> logHubContext,
IFirebaseNotificationService firebaseService,
ILogger<BackendMqttBridge> logger,
IFinlyticLogger<BackendMqttBridge> finlyticLogger) : base(logger)
{
_configuration = configuration;
_scopeFactory = scopeFactory;
_tradeStreamHubContext = tradeStreamHubContext;
_newsHubContext = newsHubContext;
_logHubContext = logHubContext;
_firebaseService = firebaseService;
_logger = logger;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = MqttConfiguration.FromConfiguration(_configuration, "finlytic_backend_gateway");
_logger.LogInformation("Starting Backend MQTT Gateway Bridge. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Backend MQTT Gateway Bridge.");
await DisconnectAsync();
}
/// <inheritdoc />
protected override async Task OnConnectedAsync()
{
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
"[BackendMqttBridge] Backend MQTT Gateway Bridge connected. Subscribing to broadcast topics...");
// RPC response stream
await SubscribeAsync(MqttTopics.ResponseWildcard);
// Engine & Bot streams
await SubscribeAsync(MqttTopics.EngineWildcard);
await SubscribeAsync(MqttTopics.BotWildcard);
// News & Sentiment streams
await SubscribeAsync(MqttTopics.NewsCompleted);
await SubscribeAsync(MqttTopics.NewsStreamWildcard);
await SubscribeAsync(MqttTopics.SentimentWildcard);
// Real-time Logs
await SubscribeAsync(MqttTopics.LogsWildcard);
// Universe RPC Endpoint for FinlyticTechnicals
await SubscribeRpcAsync<object, List<string>>(
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetAggregatedFavorites),
HandleGetAggregatedFavoritesRpcAsync);
// Username lookup RPC Endpoint for FinlyticNotify
await SubscribeRpcAsync<UserIdRequest, string?>(
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetUsername),
HandleGetUsernameRpcAsync);
// FinlyticBackend previously never broadcast its OWN structured logs at all (it only relayed other
// services' logs received on MqttTopics.LogsWildcard, subscribed above) - it never used
// IFinlyticLogger<T>, so FinlyticLogBroadcaster.Broadcast was never invoked for anything happening
// inside FinlyticBackend itself. This hook publishes FinlyticBackend's own logs onto the exact same
// finlytic/logs/FinlyticBackend topic every other service publishes to, which the wildcard
// subscription above then picks straight back up and relays into LogStreamHub like any other
// service's messages - no separate "local echo" mechanism needed.
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBackend", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync(MqttTopics.Logs("FinlyticBackend"), logDto);
}
};
}
/// <inheritdoc />
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
{
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
try
{
if (topic.StartsWith(MqttTopics.LogsPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleLogMessageAsync(payloadStr);
}
else if (topic.StartsWith(MqttTopics.EngineProposalsPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleEngineProposalAsync(payloadStr);
}
else if (topic.StartsWith(MqttTopics.EngineTradesPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleEngineTradeUpdateAsync(payloadStr);
}
else if (topic.StartsWith(MqttTopics.BotTradesPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleBotTradeUpdateAsync(payloadStr);
}
else if (topic.Equals(MqttTopics.NewsCompleted, StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith(MqttTopics.NewsPrefix, StringComparison.OrdinalIgnoreCase) ||
topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase))
{
await HandleNewsArticleAsync(payloadStr);
}
}
catch (Exception ex)
{
await _finlyticLogger.LogErrorAsync(BackendSettingKeys.BackendChannel, ex,
"[BackendMqttBridge] Error processing message on topic {Topic}", topic);
}
}
private async Task HandleLogMessageAsync(string payloadStr)
{
var logDto = JsonSerializer.Deserialize<LogMessageDto>(payloadStr, FinlyticJsonSerializerContext.Default.LogMessageDto);
if (logDto == null) return;
string serviceKey = logDto.ServiceName;
var queue = ServiceLogsRingBuffer.GetOrAdd(serviceKey, _ => new ConcurrentQueue<LogMessageDto>());
queue.Enqueue(logDto);
// Keep buffer capped at 250 entries
while (queue.Count > 250 && queue.TryDequeue(out _)) { }
// Broadcast once to all connected SignalR admin logs clients
await _logHubContext.Clients.All.SendAsync("ReceiveLogMessage", logDto);
}
private async Task HandleEngineProposalAsync(string payloadStr)
{
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr);
if (proposal == null) return;
await _tradeStreamHubContext.Clients.All.ReceiveTradeProposal(proposal);
if (!string.IsNullOrWhiteSpace(proposal.UnderlyingIsin))
{
await _tradeStreamHubContext.Clients.Group(proposal.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeProposal(proposal);
}
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Proposal {ProposalId} for {Isin} via SignalR TradeStreamHub.", proposal.ProposalId, proposal.UnderlyingIsin);
// FCM Push Notification for High Score Proposals (Score >= 75)
if (proposal.CompositeScore >= 75)
{
try
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
if (fcmTokens.Count > 0)
{
await _firebaseService.SendTradeProposalNotificationAsync(proposal, fcmTokens);
}
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
"[BackendMqttBridge] Failed to send FCM push notification for proposal {ProposalId}.", proposal.ProposalId);
}
}
}
private async Task HandleEngineTradeUpdateAsync(string payloadStr)
{
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr);
if (trade == null) return;
await _tradeStreamHubContext.Clients.All.ReceiveTradeUpdate(trade);
if (!string.IsNullOrWhiteSpace(trade.UnderlyingIsin))
{
await _tradeStreamHubContext.Clients.Group(trade.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeUpdate(trade);
}
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Update {TradeId} (Status: {Status}) via SignalR TradeStreamHub.", trade.TradeId, trade.Status);
// Push notification on important state transitions (e.g. stopped out or TP2 hit)
if (trade.Status == TradeStatus.StoppedOut || trade.Status == TradeStatus.Tp2Hit || trade.Status == TradeStatus.Closed)
{
try
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
if (fcmTokens.Count > 0)
{
await _firebaseService.SendTradeUpdateNotificationAsync(trade, fcmTokens);
}
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
"[BackendMqttBridge] Failed to send FCM push notification for trade update {TradeId}.", trade.TradeId);
}
}
}
private async Task HandleBotTradeUpdateAsync(string payloadStr)
{
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(payloadStr, DefaultJsonOptions);
if (botTrade != null)
{
await _tradeStreamHubContext.Clients.All.ReceiveBotPositionUpdate(botTrade);
if (!string.IsNullOrWhiteSpace(botTrade.Isin))
{
await _tradeStreamHubContext.Clients.Group(botTrade.Isin.ToUpperInvariant()).ReceiveBotPositionUpdate(botTrade);
}
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted Bot trade update for {Isin} ({Symbol}, Status: {Status}) via SignalR TradeStreamHub.", botTrade.Isin, botTrade.Symbol, botTrade.Status);
}
}
private async Task HandleNewsArticleAsync(string payloadStr)
{
var article = JsonSerializer.Deserialize<NewsArticleDto>(payloadStr, FinlyticJsonSerializerContext.Default.NewsArticleDto);
if (article == null) return;
await _newsHubContext.Clients.All.SendAsync("ReceiveNewArticle", article);
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
"[BackendMqttBridge] Broadcasted live news item '{Title}' over SignalR NewsHub.", article.Title);
}
private async Task<List<string>> HandleGetAggregatedFavoritesRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var isins = await dbContext.UserFavoriteAssets
.AsNoTracking()
.Select(f => f.Isin)
.Where(isin => !string.IsNullOrWhiteSpace(isin))
.Distinct()
.ToListAsync();
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
"[BackendMqttBridge] Responded to backend_GetAggregatedFavorites with {Count} unique ISINs. [CorrelationId: {CorrelationId}]", isins.Count, correlationId);
return isins;
}
private async Task<string?> HandleGetUsernameRpcAsync(UserIdRequest? req, string correlationId)
{
if (req == null || req.UserId == Guid.Empty) return null;
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.Id == req.UserId);
if (user == null) return null;
string username = !string.IsNullOrWhiteSpace(user.FullName)
? user.FullName.Trim().ToLowerInvariant().Replace(" ", "_")
: user.Email.Split('@')[0].Trim().ToLowerInvariant();
return username;
}
}