005d4e794a
- 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).
63 lines
2.6 KiB
C#
63 lines
2.6 KiB
C#
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;
|
|
}
|
|
}
|