Files
maraphon-app/src/Marathon.Infrastructure/Notifications/TelegramNotificationSink.cs
T
alexei.dolgolyov 005d4e794a feat(notifications): config-gated Telegram anomaly alerts
- INotificationSink + AnomalyNotification (Application) and a testable
  GetPendingAnomalyNotificationsUseCase (date+score filter, event-title join,
  oldest-first). 4 use-case tests.
- TelegramNotificationSink posts to the Bot API via HttpClient (no SDK dependency);
  no-ops with a warning when unconfigured and never logs the token.
- AnomalyNotificationDispatcher BackgroundService: startup-baselined marker advanced
  past the newest sent (gap- and dup-free); idles until Notifications:Enabled.
- Wire options + named client + sink + dispatcher in InfrastructureModule. Add a
  secret-free Notifications section + steam-move tuning to appsettings.json
  (bot token + chat id go in appsettings.Local.json only).
2026-05-29 00:59:57 +03:00

80 lines
3.1 KiB
C#

using System.Net.Http.Json;
using Marathon.Application.Abstractions;
using Marathon.Infrastructure.Configuration;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Marathon.Infrastructure.Notifications;
/// <summary>
/// Delivers anomaly alerts to a Telegram chat via the Bot API <c>sendMessage</c>
/// endpoint, using a plain <see cref="HttpClient"/> (no third-party SDK dependency).
/// </summary>
/// <remarks>
/// No-ops with a warning when the bot token or chat id is not configured, so a
/// half-configured deployment degrades gracefully rather than throwing. The bot token
/// is never logged (it sits in the request URL only).
/// </remarks>
internal sealed class TelegramNotificationSink : INotificationSink
{
public const string HttpClientName = "telegram";
private readonly IHttpClientFactory _factory;
private readonly IOptionsMonitor<NotificationOptions> _opts;
private readonly ILogger<TelegramNotificationSink> _logger;
public TelegramNotificationSink(
IHttpClientFactory factory,
IOptionsMonitor<NotificationOptions> opts,
ILogger<TelegramNotificationSink> logger)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task SendAsync(AnomalyNotification notification, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(notification);
var opts = _opts.CurrentValue;
if (string.IsNullOrWhiteSpace(opts.TelegramBotToken) || string.IsNullOrWhiteSpace(opts.TelegramChatId))
{
_logger.LogWarning(
"TelegramNotificationSink: bot token / chat id not configured — skipping notification {AnomalyId}.",
notification.AnomalyId);
return;
}
var payload = new
{
chat_id = opts.TelegramChatId,
text = FormatMessage(notification),
disable_web_page_preview = true,
};
var client = _factory.CreateClient(HttpClientName);
var requestUri = $"https://api.telegram.org/bot{opts.TelegramBotToken}/sendMessage";
try
{
using var response = await client.PostAsJsonAsync(requestUri, payload, ct).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
// Never log the URL/token — only the status.
_logger.LogWarning(
"TelegramNotificationSink: send failed for {AnomalyId} with status {Status}.",
notification.AnomalyId, (int)response.StatusCode);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex,
"TelegramNotificationSink: send threw for {AnomalyId}.", notification.AnomalyId);
}
}
private static string FormatMessage(AnomalyNotification n) =>
$"⚠ {n.Kind}\n{n.EventTitle}\nScore {n.Score:0.00} · {n.DetectedAt:yyyy-MM-dd HH:mm} MSK";
}