feat(notify): add FinlyticNotify push notification microservice and test suite

This commit is contained in:
2026-09-01 17:38:01 +02:00
parent 6a0f9af3f8
commit c5d7d359ba
17 changed files with 1677 additions and 0 deletions
@@ -0,0 +1,260 @@
using System.Globalization;
using System.Linq;
using System.Text;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticNotify.Services;
/// <summary>
/// Service interface for transforming trading and news events into structured ntfy push notifications.
/// </summary>
public interface INotificationFormatter
{
/// <summary>
/// Formats a new trade proposal into a high-priority opportunity notification.
/// </summary>
NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic);
/// <summary>
/// Formats an active trade lifecycle change into a user-specific status notification.
/// </summary>
NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic);
/// <summary>
/// Formats an automated paper-trading bot execution event into a notification.
/// </summary>
NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic);
/// <summary>
/// Formats an analyzed news article with sentiment evaluation into a push notification.
/// </summary>
NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic);
}
/// <summary>
/// Implementation of <see cref="INotificationFormatter"/> that creates emoji-rich Markdown messages
/// formatted for the ntfy mobile/web applications.
/// </summary>
public class NotificationFormatter : INotificationFormatter
{
/// <inheritdoc />
public NtfyNotification FormatProposalNotification(TradeProposalDto proposal, string targetTopic)
{
bool isBuy = proposal.Direction == SignalDirection.Buy;
string dirEmoji = isBuy ? "🟢" : "🔴";
string dirText = isBuy ? "Long" : "Short";
string title = $"{dirEmoji} Neuer Trade-Vorschlag: {proposal.Symbol} ({dirText})";
var tags = new List<string>
{
isBuy ? "chart_with_upwards_trend" : "chart_with_downwards_trend",
"moneybag",
"dart"
};
int priority = proposal.CompositeScore >= 80m ? 4 : 3;
var sb = new StringBuilder();
sb.AppendLine($"**Strategie:** {proposal.StrategyKey} | **Score:** {proposal.CompositeScore:F1}/100");
sb.AppendLine($"**Einstieg:** {proposal.EntryPrice:F2} €");
sb.AppendLine($"**Stop-Loss:** {proposal.InvalidationPrice:F2} €");
var tp1 = proposal.ExitPlan?.TakeProfitStages?.FirstOrDefault();
if (tp1 != null)
{
sb.AppendLine($"**Ziel (TP1):** {tp1.TargetPrice:F2} € ({tp1.Description})");
}
if (proposal.SelectedDerivative != null)
{
sb.AppendLine($"**Knock-Out:** {proposal.SelectedDerivative.Issuer} ({proposal.SelectedDerivative.OptionType}, Hebel: {proposal.SelectedDerivative.Leverage:F1}x)");
}
if (!string.IsNullOrWhiteSpace(proposal.AiValidation?.ThesisSummary))
{
sb.AppendLine();
sb.AppendLine($"**KI-These:** {proposal.AiValidation.ThesisSummary}");
}
return new NtfyNotification(
Topic: targetTopic,
Title: title,
Message: sb.ToString().TrimEnd(),
Priority: priority,
Tags: tags
);
}
/// <inheritdoc />
public NtfyNotification FormatTradeStatusNotification(ActiveTradeDto trade, string targetTopic)
{
string dirText = trade.Direction == SignalDirection.Buy ? "Long" : "Short";
return trade.Status switch
{
TradeStatus.Active or TradeStatus.Proposed => new NtfyNotification(
Topic: targetTopic,
Title: $"⚡ Trade aktiv: {trade.Symbol} ({dirText})",
Message: $"**Buy-In:** {trade.AverageBuyIn:F2} € | **Menge:** {trade.TotalQuantity:F2}\n" +
$"**Initialer Stop-Loss:** {trade.InitialStopLoss:F2} €\n" +
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} €",
Priority: 3,
Tags: ["zap", "white_check_mark"]
),
TradeStatus.Tp1Hit => new NtfyNotification(
Topic: targetTopic,
Title: $"🎯 Teilgewinn erreicht (TP1): {trade.Symbol} (+{trade.UnrealizedPnlPercent:F1}%)",
Message: $"**Gewinn:** +{trade.UnrealizedPnlEur:F2} € (+{trade.UnrealizedPnlPercent:F1}%)\n" +
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
$"**Neuer Stop-Loss:** {trade.CurrentStopLoss:F2} € (Break-Even gesichert)",
Priority: 4,
Tags: ["tada", "dart", "chart_with_upwards_trend"]
),
TradeStatus.Tp2Hit => new NtfyNotification(
Topic: targetTopic,
Title: $"🏆 Vollziel erreicht (TP2): {trade.Symbol} (+{trade.RealizedPnlEur:F2} €)",
Message: $"**Realisierter Gewinn:** +{trade.RealizedPnlEur:F2} €\n" +
$"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
$"**Status:** Trade erfolgreich mit Maximalziel abgeschlossen!",
Priority: 4,
Tags: ["trophy", "money_with_wings", "star2"]
),
TradeStatus.StoppedOut => new NtfyNotification(
Topic: targetTopic,
Title: $"🛑 Stop-Loss ausgelöst: {trade.Symbol} ({trade.RealizedPnlEur:F2} €)",
Message: $"**Verlust:** {trade.RealizedPnlEur:F2} €\n" +
$"**Ausstiegskurs:** {trade.CurrentPrice:F2} € (Stop war bei {trade.CurrentStopLoss:F2} €)\n" +
$"**Status:** Position durch Stop-Loss risikokontrolliert geschlossen.",
Priority: 4,
Tags: ["octagonal_sign", "warning", "shield"]
),
TradeStatus.Closed => new NtfyNotification(
Topic: targetTopic,
Title: $"🏁 Trade geschlossen: {trade.Symbol} (G/V: {trade.RealizedPnlEur:F2} €)",
Message: $"**Realisierter G/V:** {trade.RealizedPnlEur:F2} €\n" +
$"**Schlusskurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)",
Priority: 3,
Tags: ["checkered_flag", "information_source"]
),
_ => new NtfyNotification(
Topic: targetTopic,
Title: $"🛡️ Trade Update: {trade.Symbol} ({trade.Status})",
Message: $"**Aktueller Stop-Loss:** {trade.CurrentStopLoss:F2} €\n" +
$"**Aktueller Kurs:** {trade.CurrentPrice:F2} € (Buy-In: {trade.AverageBuyIn:F2} €)\n" +
$"**Unrealisierter G/V:** {trade.UnrealizedPnlEur:F2} € ({trade.UnrealizedPnlPercent:F1}%)",
Priority: 2,
Tags: ["shield", "chart"]
)
};
}
/// <inheritdoc />
public NtfyNotification FormatBotTradeNotification(BotTradeOrderDto botTrade, string targetTopic)
{
string dirText = botTrade.Direction == SignalDirection.Buy ? "Long" : "Short";
string title = $"🤖 Bot Trade [{botTrade.Status}]: {botTrade.Symbol} ({dirText})";
var tags = new List<string> { "robot", "chart" };
if (botTrade.Status == BotPositionStatus.Tp1Hit || botTrade.Status == BotPositionStatus.Tp2Hit) tags.Add("dart");
if (botTrade.Status == BotPositionStatus.StoppedOut) tags.Add("warning");
var sb = new StringBuilder();
sb.AppendLine($"**Venue:** {botTrade.Venue} | **Status:** {botTrade.Status}");
sb.AppendLine($"**Buy-In:** {botTrade.AverageBuyIn:F2} € | **Menge:** {botTrade.FilledQuantity:F2}");
sb.AppendLine($"**Stop-Loss:** {botTrade.CurrentStopLoss:F2} €");
sb.AppendLine($"**Aktueller Kurs:** {botTrade.CurrentPrice:F2} €");
if (botTrade.Status == BotPositionStatus.Closed || botTrade.Status == BotPositionStatus.StoppedOut || botTrade.Status == BotPositionStatus.Tp2Hit)
{
sb.AppendLine($"**Realisierter G/V:** {botTrade.RealizedPnlEur:F2} €");
}
else
{
sb.AppendLine($"**Unrealisierter G/V:** {botTrade.UnrealizedPnlEur:F2} €");
}
return new NtfyNotification(
Topic: targetTopic,
Title: title,
Message: sb.ToString().TrimEnd(),
Priority: 3,
Tags: tags
);
}
/// <inheritdoc />
public NtfyNotification FormatNewsNotification(NewsArticleDto article, string targetTopic)
{
string sentimentLabel = (article.Sentiment ?? "NEUTRAL").ToUpperInvariant();
double score = article.SentimentScore ?? 0.0;
double confidence = article.Confidence ?? 0.0;
string sentimentEmoji = sentimentLabel switch
{
"POSITIVE" => "🟢",
"NEGATIVE" => "🔴",
_ => "⚪"
};
string primaryAsset = article.MatchedAssets?.FirstOrDefault()?.Name
?? article.MatchedAssets?.FirstOrDefault()?.Isin
?? "Markt";
string title = $"{sentimentEmoji} News ({sentimentLabel}): {primaryAsset}";
var tags = new List<string> { "newspaper" };
if (sentimentLabel == "POSITIVE")
{
tags.Add("chart_with_upwards_trend");
tags.Add("tada");
}
else if (sentimentLabel == "NEGATIVE")
{
tags.Add("chart_with_downwards_trend");
tags.Add("warning");
}
else
{
tags.Add("information_source");
}
int priority = (confidence >= 0.8 && Math.Abs(score) >= 0.6) ? 4 : 3;
var sb = new StringBuilder();
sb.AppendLine($"**{article.Title}**");
sb.AppendLine();
sb.AppendLine($"**Sentiment:** {sentimentLabel} (Score: {score:+0.00;-0.00;0.00} | Konfidenz: {confidence:P0})");
if (article.MatchedAssets != null && article.MatchedAssets.Count > 0)
{
var assetList = string.Join(", ", article.MatchedAssets.Select(a => $"{a.Name} ({a.Isin})"));
sb.AppendLine($"**Assets:** {assetList}");
}
if (!string.IsNullOrWhiteSpace(article.Summary))
{
sb.AppendLine();
sb.AppendLine($"_{article.Summary}_");
}
sb.AppendLine();
sb.AppendLine($"**Veröffentlicht:** {article.PublishedAt:dd.MM.yyyy HH:mm} UTC");
return new NtfyNotification(
Topic: targetTopic,
Title: title,
Message: sb.ToString().TrimEnd(),
Priority: priority,
Tags: tags,
ClickUrl: !string.IsNullOrWhiteSpace(article.SourceUrl) ? article.SourceUrl : null
);
}
}
+337
View File
@@ -0,0 +1,337 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.News;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Managed MQTT client for FinlyticNotify. Listens strictly to existing MQTT broadcast topics,
/// resolves trade ownership, and dispatches rich push notifications via ntfy.
/// </summary>
public class NotifyMqttClient : ManagedMqttClient, IHostedService
{
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
private readonly INtfyClient _ntfyClient;
private readonly INotificationFormatter _formatter;
private readonly IUserTradeResolver _userTradeResolver;
private readonly ISettingsService? _settingsService;
private readonly IFinlyticLogger<NotifyMqttClient>? _finlyticLogger;
private readonly ILogger<NotifyMqttClient> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="NotifyMqttClient"/> class.
/// </summary>
public NotifyMqttClient(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
INtfyClient ntfyClient,
INotificationFormatter formatter,
IUserTradeResolver userTradeResolver,
ILogger<NotifyMqttClient> logger,
ISettingsService? settingsService = null,
IFinlyticLogger<NotifyMqttClient>? finlyticLogger = null) : base(logger)
{
_configuration = configuration;
_scopeFactory = scopeFactory;
_ntfyClient = ntfyClient;
_formatter = formatter;
_userTradeResolver = userTradeResolver;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticNotify");
_logger.LogInformation("[NotifyMqttClient] Starting FinlyticNotify MQTT client (Broker: {Host}:{Port}, ClientId: {ClientId})",
config.Host, config.Port, config.ClientId);
await ConnectAsync(config);
}
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel, "[NotifyMqttClient] Stopping FinlyticNotify MQTT client.");
}
await DisconnectAsync();
}
/// <inheritdoc />
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("[NotifyMqttClient] Connected to MQTT broker. Subscribing to trade event topics...");
await SubscribeAsync(MqttTopics.ResponseWildcard);
// 1. Subscribe to Trade Proposals (New Trades / Setups)
await SubscribeAsync(MqttTopics.EngineProposalsCreated);
// 2. Subscribe to Trade Lifecycle Status Changes (Fills, SL-Updates, TPs, Exits)
await SubscribeAsync(MqttTopics.EngineTradesStatusChanged);
// 3. Subscribe to Bot Paper-Trading Streams
await SubscribeAsync(MqttTopics.BotTradesStream);
// 4. Subscribe to News Status Update requests to capture analyzed news events
await SubscribeAsync<UpdateNewsStatusRequest>(
MqttTopics.RequestFilter(MqttTopics.Channels.NewsUpdateStatus), HandleNewsStatusUpdateRequestAsync);
// 5. Subscribe to Service Health Ping for fleet monitoring
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingTopicAsync);
// 6. Subscribe to Dynamic Settings RPC Channels
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(
MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsGetAll), HandleSettingsGetAllRpcAsync);
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(
MqttTopics.RequestFilter(MqttTopics.Channels.NotifySettingsUpdate), HandleSettingsUpdateRpcAsync);
// 7. Wire structured log broadcasting over MQTT
FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticNotify", StringComparison.OrdinalIgnoreCase))
{
await PublishAsync(MqttTopics.Logs("FinlyticNotify"), logDto);
}
};
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.MqttChannel,
"[NotifyMqttClient] FinlyticNotify MQTT client connected and subscribed to trade and news events.");
}
}
/// <inheritdoc />
protected override async Task OnMessageReceivedAsync(string topic, string payloadStr)
{
if (string.IsNullOrWhiteSpace(topic) || string.IsNullOrWhiteSpace(payloadStr)) return;
string topicPrefix = "finlytic";
if (_settingsService != null)
{
topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix);
}
else
{
topicPrefix = _configuration.GetValue<string>("Ntfy:TopicPrefix") ?? NotifySettingKeys.NtfyTopicPrefix.DefaultValue;
}
topicPrefix = topicPrefix.Trim('/');
try
{
// Case A: New Trade Proposals created by FinlyticEngine
if (topic.Equals(MqttTopics.EngineProposalsCreated, StringComparison.OrdinalIgnoreCase))
{
await HandleProposalCreatedAsync(payloadStr, topicPrefix);
}
// Case B: Trade Status Changed (Lifecycle updates for active trades)
else if (topic.Equals(MqttTopics.EngineTradesStatusChanged, StringComparison.OrdinalIgnoreCase))
{
await HandleTradeStatusChangedAsync(payloadStr, topicPrefix);
}
// Case C: Bot Paper-Trading Execution stream
else if (topic.Equals(MqttTopics.BotTradesStream, StringComparison.OrdinalIgnoreCase))
{
await HandleBotTradeStreamAsync(payloadStr, topicPrefix);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotifyChannel, ex,
"[NotifyMqttClient] Unexpected error handling message on topic {Topic}", topic);
}
}
}
private async Task HandleProposalCreatedAsync(string payloadStr, string topicPrefix)
{
bool notifyOnProposals = true;
decimal minScore = 70.0m;
string broadcastChannel = "broadcast";
if (_settingsService != null)
{
notifyOnProposals = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnProposals);
minScore = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyMinProposalScore);
broadcastChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBroadcastChannel);
}
else
{
notifyOnProposals = _configuration.GetValue<bool>("Ntfy:NotifyOnProposals", true);
minScore = _configuration.GetValue<decimal>("Ntfy:MinProposalScore", 70.0m);
broadcastChannel = _configuration.GetValue<string>("Ntfy:BroadcastChannel") ?? "broadcast";
}
if (!notifyOnProposals) return;
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr, DefaultJsonOptions);
if (proposal == null) return;
if (proposal.CompositeScore < minScore)
{
_logger.LogDebug("[NotifyMqttClient] Skipping proposal {ProposalId}: CompositeScore {Score} < MinScore {MinScore}",
proposal.ProposalId, proposal.CompositeScore, minScore);
return;
}
string targetTopic = $"{topicPrefix}_{broadcastChannel}";
var notification = _formatter.FormatProposalNotification(proposal, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleTradeStatusChangedAsync(string payloadStr, string topicPrefix)
{
bool notifyOnTradeUpdates = true;
if (_settingsService != null)
{
notifyOnTradeUpdates = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnTradeUpdates);
}
else
{
notifyOnTradeUpdates = _configuration.GetValue<bool>("Ntfy:NotifyOnTradeUpdates", true);
}
if (!notifyOnTradeUpdates) return;
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr, DefaultJsonOptions);
if (trade == null) return;
// Resolve which user owns this trade
string username = await _userTradeResolver.ResolveUsernameByUserIdAsync(trade.UserId);
string targetTopic = $"{topicPrefix}_{username}";
var notification = _formatter.FormatTradeStatusNotification(trade, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleBotTradeStreamAsync(string payloadStr, string topicPrefix)
{
bool notifyOnBotTrades = true;
if (_settingsService != null)
{
notifyOnBotTrades = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnBotTrades);
}
else
{
notifyOnBotTrades = _configuration.GetValue<bool>("Ntfy:NotifyOnBotTrades", true);
}
if (!notifyOnBotTrades) return;
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(payloadStr, DefaultJsonOptions);
if (botTrade == null) return;
string targetTopic = $"{topicPrefix}_bot";
var notification = _formatter.FormatBotTradeNotification(botTrade, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
private async Task HandleNewsStatusUpdateRequestAsync(UpdateNewsStatusRequest? req, string topic, string correlationId)
{
if (req == null || req.Id == Guid.Empty) return;
// Only trigger push notifications when an article's status is transitioning to "Analyzed"
if (!string.Equals(req.Status, "Analyzed", StringComparison.OrdinalIgnoreCase)) return;
bool notifyOnNews = true;
string newsChannel = "news";
string topicPrefix = "finlytic";
if (_settingsService != null)
{
notifyOnNews = await _settingsService.GetSettingAsync(NotifySettingKeys.NotifyOnNews);
newsChannel = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyNewsChannel);
topicPrefix = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyTopicPrefix);
}
else
{
notifyOnNews = _configuration.GetValue<bool>("Ntfy:NotifyOnNews", true);
newsChannel = _configuration.GetValue<string>("Ntfy:NewsChannel") ?? "news";
topicPrefix = _configuration.GetValue<string>("Ntfy:TopicPrefix") ?? "finlytic";
}
if (!notifyOnNews) return;
try
{
// Query FinlyticNews via existing news_GetById RPC channel to get the full enriched NewsArticleDto
var article = await SendRpcRequestAsync<NewsArticleDto, ArticleRequest>(
MqttTopics.Channels.NewsGetById,
new ArticleRequest(req.Id.ToString(), req.Id.ToString()),
TimeSpan.FromSeconds(5));
if (article != null)
{
string targetTopic = $"{topicPrefix.Trim('/')}_{newsChannel}";
var notification = _formatter.FormatNewsNotification(article, targetTopic);
await _ntfyClient.SendNotificationAsync(notification);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[NotifyMqttClient] Failed to fetch analyzed news article {ArticleId} via news_GetById RPC.", req.Id);
}
}
private async Task HandleHealthPingTopicAsync(object? _, string topic, string correlationId)
{
if (topic.Contains("FinlyticNotify", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase))
{
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticNotify", "Online", DateTime.UtcNow, "Connected"));
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.HealthPingChannel,
"[FinlyticNotify] Responded to live health_Ping RPC [CorrelationId: {CorrelationId}].", correlationId);
}
}
}
private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
}
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
}
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(NotifySettingKeys) });
}
}
+188
View File
@@ -0,0 +1,188 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Services;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Model representing a notification payload to be dispatched via ntfy.
/// </summary>
public record NtfyNotification(
string Topic,
string Title,
string Message,
int Priority = 3,
List<string>? Tags = null,
string? ClickUrl = null
);
/// <summary>
/// Client interface for sending push notifications to an ntfy instance.
/// </summary>
public interface INtfyClient
{
/// <summary>
/// Sends a notification to the specified ntfy topic.
/// </summary>
/// <param name="notification">The notification payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>True if successfully delivered, false otherwise.</returns>
Task<bool> SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default);
}
/// <summary>
/// High-performance HTTP client for dispatching push notifications to self-hosted ntfy server via JSON payload.
/// </summary>
public class NtfyClient : INtfyClient
{
private readonly HttpClient _httpClient;
private readonly ISettingsService? _settingsService;
private readonly IConfiguration _configuration;
private readonly IFinlyticLogger<NtfyClient>? _finlyticLogger;
private readonly ILogger<NtfyClient> _logger;
private static readonly JsonSerializerOptions JsonOptions = new()
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <summary>
/// Initializes a new instance of the <see cref="NtfyClient"/> class.
/// </summary>
public NtfyClient(
HttpClient httpClient,
IConfiguration configuration,
ILogger<NtfyClient> logger,
ISettingsService? settingsService = null,
IFinlyticLogger<NtfyClient>? finlyticLogger = null)
{
_httpClient = httpClient;
_configuration = configuration;
_logger = logger;
_settingsService = settingsService;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task<bool> SendNotificationAsync(NtfyNotification notification, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(notification.Topic))
{
_logger.LogWarning("[NtfyClient] Aborting send: Topic is empty.");
return false;
}
string baseUrl = "http://localhost:8080";
if (_settingsService != null)
{
try
{
baseUrl = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyBaseUrl, cancellationToken);
}
catch
{
baseUrl = _configuration.GetValue<string>("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue;
}
}
else
{
baseUrl = _configuration.GetValue<string>("Ntfy:BaseUrl") ?? NotifySettingKeys.NtfyBaseUrl.DefaultValue;
}
baseUrl = baseUrl.TrimEnd('/');
// Build native ntfy JSON payload (preserves full UTF-8 Unicode, Emojis, and Markdown without HTTP header ASCII constraints)
var payload = new Dictionary<string, object?>
{
["topic"] = notification.Topic.TrimStart('/'),
["title"] = notification.Title,
["message"] = notification.Message,
["priority"] = Math.Clamp(notification.Priority, 1, 5),
["tags"] = notification.Tags,
["click"] = notification.ClickUrl,
["markdown"] = true
};
string json = JsonSerializer.Serialize(payload, JsonOptions);
string? token = null;
string? authUser = null;
string? authPass = null;
if (_settingsService != null)
{
try
{
token = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyAuthToken, cancellationToken);
authUser = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyUsername, cancellationToken);
authPass = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyPassword, cancellationToken);
}
catch { }
}
token = string.IsNullOrWhiteSpace(token) ? _configuration.GetValue<string>("Ntfy:AuthToken") : token;
authUser = string.IsNullOrWhiteSpace(authUser) ? _configuration.GetValue<string>("Ntfy:Username") : authUser;
authPass = string.IsNullOrWhiteSpace(authPass) ? _configuration.GetValue<string>("Ntfy:Password") : authPass;
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, baseUrl);
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token.Trim());
}
else if (!string.IsNullOrWhiteSpace(authUser) && !string.IsNullOrWhiteSpace(authPass))
{
var authBytes = Encoding.UTF8.GetBytes($"{authUser}:{authPass}");
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Basic", Convert.ToBase64String(authBytes));
}
var response = await _httpClient.SendAsync(request, cancellationToken);
if (response.IsSuccessStatusCode)
{
_logger.LogInformation("[NtfyClient] Push notification successfully delivered to {TargetUrl} (Topic: {Topic})", baseUrl, notification.Topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogInfoAsync(NotifySettingKeys.NotificationDeliveryChannel,
"[NtfyDelivery] Push notification successfully delivered to topic '{Topic}' (HTTP {StatusCode})",
notification.Topic, (int)response.StatusCode);
}
return true;
}
string errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
_logger.LogWarning("[NtfyClient] Failed to send notification to {TargetUrl} for topic {Topic}. HTTP {Status}: {Body}",
baseUrl, notification.Topic, (int)response.StatusCode, errorBody);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogWarningAsync(NotifySettingKeys.NotificationDeliveryChannel,
"[NtfyDelivery] Failed to send push notification to topic '{Topic}' (HTTP {StatusCode})",
notification.Topic, (int)response.StatusCode);
}
return false;
}
catch (Exception ex)
{
_logger.LogError(ex, "[NtfyClient] Unexpected error sending push notification to {TargetUrl} for topic {Topic}", baseUrl, notification.Topic);
if (_finlyticLogger != null)
{
await _finlyticLogger.LogErrorAsync(NotifySettingKeys.NotificationDeliveryChannel, ex,
"[NtfyDelivery] Error sending push notification to topic '{Topic}'", notification.Topic);
}
return false;
}
}
}
@@ -0,0 +1,113 @@
using System;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Dtos;
using FinlyticCore.Services;
using FinlyticCore.Util;
using FinlyticNotify.Settings;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticNotify.Services;
/// <summary>
/// Service interface for resolving a UserId GUID to a clean username for ntfy channel addressing.
/// </summary>
public interface IUserTradeResolver
{
/// <summary>
/// Resolves the clean username for a given UserId GUID via in-memory cache and FinlyticBackend MQTT RPC.
/// </summary>
/// <param name="userId">The unique ID of the user owning the trade.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved username (e.g. "lars", "kleidukos", "admin") for ntfy channel addressing.</returns>
Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default);
}
/// <summary>
/// Implementation of <see cref="IUserTradeResolver"/> performing cached MQTT RPC calls to FinlyticBackend
/// without any direct cross-database dependencies.
/// </summary>
public class UserTradeResolver : IUserTradeResolver
{
private readonly IServiceProvider _serviceProvider;
private readonly IMemoryCache _cache;
private readonly ISettingsService _settingsService;
private readonly IConfiguration _configuration;
private readonly ILogger<UserTradeResolver> _logger;
private static readonly Regex InvalidChannelCharRegex = new("[^a-zA-Z0-9_-]", RegexOptions.Compiled);
/// <summary>
/// Initializes a new instance of the <see cref="UserTradeResolver"/> class.
/// </summary>
public UserTradeResolver(
IServiceProvider serviceProvider,
IMemoryCache cache,
ISettingsService settingsService,
IConfiguration configuration,
ILogger<UserTradeResolver> logger)
{
_serviceProvider = serviceProvider;
_cache = cache;
_settingsService = settingsService;
_configuration = configuration;
_logger = logger;
}
/// <inheritdoc />
public async Task<string> ResolveUsernameByUserIdAsync(Guid userId, CancellationToken cancellationToken = default)
{
string defaultUsername = await _settingsService.GetSettingAsync(NotifySettingKeys.NtfyDefaultUsername, cancellationToken);
if (string.IsNullOrWhiteSpace(defaultUsername))
{
defaultUsername = _configuration.GetValue<string>("Ntfy:DefaultUsername") ?? "admin";
}
if (userId == Guid.Empty)
{
return defaultUsername;
}
string cacheKey = $"user_name_{userId}";
if (_cache.TryGetValue(cacheKey, out string? cachedUsername) && !string.IsNullOrWhiteSpace(cachedUsername))
{
return cachedUsername;
}
try
{
var mqttClient = _serviceProvider.GetService<NotifyMqttClient>();
if (mqttClient != null && mqttClient.IsConnected)
{
var username = await mqttClient.SendRpcRequestAsync<string, UserIdRequest>(
MqttTopics.Channels.BackendGetUsername,
new UserIdRequest(userId),
TimeSpan.FromSeconds(2));
if (!string.IsNullOrWhiteSpace(username))
{
string cleanChannel = CleanUsername(username);
_cache.Set(cacheKey, cleanChannel, TimeSpan.FromHours(1));
return cleanChannel;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[UserTradeResolver] Failed to resolve username from FinlyticBackend for UserId {UserId}. Falling back to default.", userId);
}
return defaultUsername;
}
private static string CleanUsername(string rawName)
{
var cleaned = rawName.Trim().ToLowerInvariant().Replace(" ", "_");
cleaned = InvalidChannelCharRegex.Replace(cleaned, "");
return string.IsNullOrWhiteSpace(cleaned) ? "admin" : cleaned;
}
}