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).
This commit is contained in:
2026-05-29 00:59:57 +03:00
parent 2e53dff853
commit 005d4e794a
9 changed files with 418 additions and 1 deletions
@@ -0,0 +1,62 @@
using Marathon.Application.Abstractions;
using Microsoft.Extensions.Logging;
namespace Marathon.Application.UseCases;
/// <summary>
/// Shapes the anomalies worth alerting on: those detected at or after a caller-supplied
/// marker whose score clears a minimum, joined with their event titles. Pure of any
/// transport concern — the dispatcher decides cadence and the sink decides delivery.
/// </summary>
/// <remarks>
/// Results are ordered oldest-first so the caller can advance its "since" marker to the
/// last item's <see cref="AnomalyNotification.DetectedAt"/> (plus one tick) without gaps
/// or duplicates.
/// </remarks>
public sealed class GetPendingAnomalyNotificationsUseCase
{
private readonly IAnomalyRepository _anomalies;
private readonly IEventRepository _events;
private readonly ILogger<GetPendingAnomalyNotificationsUseCase> _logger;
public GetPendingAnomalyNotificationsUseCase(
IAnomalyRepository anomalies,
IEventRepository events,
ILogger<GetPendingAnomalyNotificationsUseCase> logger)
{
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_events = events ?? throw new ArgumentNullException(nameof(events));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<IReadOnlyList<AnomalyNotification>> ExecuteAsync(
DateTimeOffset since,
decimal minScore,
CancellationToken ct)
{
// Date filter pushed to SQL; score filter is cheap in memory over the small slice.
var recent = await _anomalies.ListByDateRangeAsync(since, to: null, ct).ConfigureAwait(false);
var qualifying = recent.Where(a => a.Score >= minScore).ToList();
if (qualifying.Count == 0)
return Array.Empty<AnomalyNotification>();
var eventIds = qualifying.Select(a => a.EventId).Distinct().ToList();
var events = await _events.GetManyAsync(eventIds, ct).ConfigureAwait(false);
var notifications = qualifying
.OrderBy(a => a.DetectedAt)
.Select(a => new AnomalyNotification(
AnomalyId: a.Id,
EventTitle: events.TryGetValue(a.EventId, out var ev) ? ev.Title : a.EventId.Value,
Kind: a.Kind,
Score: a.Score,
DetectedAt: a.DetectedAt))
.ToList();
_logger.LogDebug(
"GetPendingAnomalyNotificationsUseCase: {Count} alert(s) since {Since:O} at minScore {MinScore}",
notifications.Count, since, minScore);
return notifications;
}
}