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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user