feat(notify): add FinlyticNotify push notification microservice and test suite
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user