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,28 @@
|
||||
using Marathon.Domain.Enums;
|
||||
|
||||
namespace Marathon.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// A ready-to-deliver anomaly alert payload, shaped by
|
||||
/// <see cref="UseCases.GetPendingAnomalyNotificationsUseCase"/> so a sink only has to
|
||||
/// format and transmit it.
|
||||
/// </summary>
|
||||
public sealed record AnomalyNotification(
|
||||
Guid AnomalyId,
|
||||
string EventTitle,
|
||||
AnomalyKind Kind,
|
||||
decimal Score,
|
||||
DateTimeOffset DetectedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A channel that delivers anomaly alerts (e.g. Telegram; future: email / Discord).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Implementations must be resilient: a failure to deliver one notification should be
|
||||
/// logged and swallowed, never thrown into the dispatcher loop. A sink that is not
|
||||
/// configured (e.g. missing credentials) should no-op with a warning.
|
||||
/// </remarks>
|
||||
public interface INotificationSink
|
||||
{
|
||||
Task SendAsync(AnomalyNotification notification, CancellationToken ct);
|
||||
}
|
||||
@@ -31,6 +31,7 @@ public static class ApplicationModule
|
||||
services.AddScoped<ExportToExcelUseCase>();
|
||||
services.AddScoped<DetectAnomaliesUseCase>();
|
||||
services.AddScoped<EvaluateAnomalyOutcomesUseCase>();
|
||||
services.AddScoped<GetPendingAnomalyNotificationsUseCase>();
|
||||
|
||||
services.AddScoped<RecordPlacedBetUseCase>();
|
||||
services.AddScoped<ResolvePendingBetsUseCase>();
|
||||
|
||||
@@ -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