up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
AOC Guard CI / aoc-guard (push) Has been cancelled
AOC Guard CI / aoc-verify (push) Has been cancelled
Concelier Attestation Tests / attestation-tests (push) Has been cancelled
Export Center CI / export-ci (push) Has been cancelled
Notify Smoke Test / Notify Unit Tests (push) Has been cancelled
Notify Smoke Test / Notifier Service Tests (push) Has been cancelled
Notify Smoke Test / Notification Smoke Test (push) Has been cancelled
Policy Lint & Smoke / policy-lint (push) Has been cancelled
Scanner Analyzers / Discover Analyzers (push) Has been cancelled
Scanner Analyzers / Build Analyzers (push) Has been cancelled
Scanner Analyzers / Test Language Analyzers (push) Has been cancelled
Scanner Analyzers / Validate Test Fixtures (push) Has been cancelled
Scanner Analyzers / Verify Deterministic Output (push) Has been cancelled
Signals CI & Image / signals-ci (push) Has been cancelled
Signals Reachability Scoring & Events / reachability-smoke (push) Has been cancelled
Signals Reachability Scoring & Events / sign-and-upload (push) Has been cancelled
This commit is contained in:
@@ -1,138 +1,138 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for resolving channel adapters by type.
|
||||
/// </summary>
|
||||
public interface IChannelAdapterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a channel adapter for the specified channel type.
|
||||
/// </summary>
|
||||
IChannelAdapter? GetAdapter(NotifyChannelType channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered channel adapters.
|
||||
/// </summary>
|
||||
IReadOnlyList<IChannelAdapter> GetAllAdapters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChannelAdapterFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterFactory : IChannelAdapterFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, IChannelAdapter> _adapters;
|
||||
private readonly IReadOnlyList<IChannelAdapter> _allAdapters;
|
||||
|
||||
public ChannelAdapterFactory(IEnumerable<IChannelAdapter> adapters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(adapters);
|
||||
|
||||
var adapterList = adapters.ToList();
|
||||
_allAdapters = adapterList.AsReadOnly();
|
||||
|
||||
var dict = new Dictionary<NotifyChannelType, IChannelAdapter>();
|
||||
foreach (var adapter in adapterList)
|
||||
{
|
||||
dict[adapter.ChannelType] = adapter;
|
||||
}
|
||||
_adapters = dict;
|
||||
}
|
||||
|
||||
public IChannelAdapter? GetAdapter(NotifyChannelType channelType)
|
||||
{
|
||||
return _adapters.GetValueOrDefault(channelType);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IChannelAdapter> GetAllAdapters() => _allAdapters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering channel adapters.
|
||||
/// </summary>
|
||||
public static class ChannelAdapterServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers channel adapters and factory.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddChannelAdapters(
|
||||
this IServiceCollection services,
|
||||
Action<ChannelAdapterOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<ChannelAdapterOptions>()
|
||||
.BindConfiguration(ChannelAdapterOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<WebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddHttpClient<ChatWebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, WebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<WebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(WebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, EmailChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapter, ChatWebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<ChatWebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(ChatWebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
// PagerDuty adapter
|
||||
services.AddHttpClient<PagerDutyChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, PagerDutyChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<PagerDutyChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(PagerDutyChannelAdapter)));
|
||||
});
|
||||
|
||||
// OpsGenie adapter
|
||||
services.AddHttpClient<OpsGenieChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, OpsGenieChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<OpsGenieChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(OpsGenieChannelAdapter)));
|
||||
});
|
||||
|
||||
// InApp adapter
|
||||
services.AddOptions<InAppChannelOptions>()
|
||||
.BindConfiguration(InAppChannelOptions.SectionName);
|
||||
services.AddSingleton<IChannelAdapter, InAppChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapterFactory, ChannelAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for resolving channel adapters by type.
|
||||
/// </summary>
|
||||
public interface IChannelAdapterFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a channel adapter for the specified channel type.
|
||||
/// </summary>
|
||||
IChannelAdapter? GetAdapter(NotifyChannelType channelType);
|
||||
|
||||
/// <summary>
|
||||
/// Gets all registered channel adapters.
|
||||
/// </summary>
|
||||
IReadOnlyList<IChannelAdapter> GetAllAdapters();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IChannelAdapterFactory"/>.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterFactory : IChannelAdapterFactory
|
||||
{
|
||||
private readonly IReadOnlyDictionary<NotifyChannelType, IChannelAdapter> _adapters;
|
||||
private readonly IReadOnlyList<IChannelAdapter> _allAdapters;
|
||||
|
||||
public ChannelAdapterFactory(IEnumerable<IChannelAdapter> adapters)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(adapters);
|
||||
|
||||
var adapterList = adapters.ToList();
|
||||
_allAdapters = adapterList.AsReadOnly();
|
||||
|
||||
var dict = new Dictionary<NotifyChannelType, IChannelAdapter>();
|
||||
foreach (var adapter in adapterList)
|
||||
{
|
||||
dict[adapter.ChannelType] = adapter;
|
||||
}
|
||||
_adapters = dict;
|
||||
}
|
||||
|
||||
public IChannelAdapter? GetAdapter(NotifyChannelType channelType)
|
||||
{
|
||||
return _adapters.GetValueOrDefault(channelType);
|
||||
}
|
||||
|
||||
public IReadOnlyList<IChannelAdapter> GetAllAdapters() => _allAdapters;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering channel adapters.
|
||||
/// </summary>
|
||||
public static class ChannelAdapterServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers channel adapters and factory.
|
||||
/// </summary>
|
||||
public static IServiceCollection AddChannelAdapters(
|
||||
this IServiceCollection services,
|
||||
Action<ChannelAdapterOptions>? configure = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
|
||||
services.AddOptions<ChannelAdapterOptions>()
|
||||
.BindConfiguration(ChannelAdapterOptions.SectionName);
|
||||
|
||||
if (configure is not null)
|
||||
{
|
||||
services.Configure(configure);
|
||||
}
|
||||
|
||||
services.AddHttpClient<WebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddHttpClient<ChatWebhookChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, WebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<WebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(WebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
services.AddSingleton<IChannelAdapter, EmailChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapter, ChatWebhookChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<ChatWebhookChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(ChatWebhookChannelAdapter)));
|
||||
});
|
||||
|
||||
// PagerDuty adapter
|
||||
services.AddHttpClient<PagerDutyChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, PagerDutyChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<PagerDutyChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(PagerDutyChannelAdapter)));
|
||||
});
|
||||
|
||||
// OpsGenie adapter
|
||||
services.AddHttpClient<OpsGenieChannelAdapter>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
});
|
||||
services.AddSingleton<IChannelAdapter, OpsGenieChannelAdapter>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<IHttpClientFactory>();
|
||||
return ActivatorUtilities.CreateInstance<OpsGenieChannelAdapter>(
|
||||
sp, factory.CreateClient(nameof(OpsGenieChannelAdapter)));
|
||||
});
|
||||
|
||||
// InApp adapter
|
||||
services.AddOptions<InAppChannelOptions>()
|
||||
.BindConfiguration(InAppChannelOptions.SectionName);
|
||||
services.AddSingleton<IChannelAdapter, InAppChannelAdapter>();
|
||||
|
||||
services.AddSingleton<IChannelAdapterFactory, ChannelAdapterFactory>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,62 @@
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for channel adapters.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "ChannelAdapters";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed dispatches.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay for exponential backoff between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delay between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual dispatch operations.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Enable HMAC signing for webhook payloads.
|
||||
/// </summary>
|
||||
public bool EnableHmacSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string for HTTP requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-Notifier/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default concurrency limit per channel type.
|
||||
/// </summary>
|
||||
public int DefaultConcurrency { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Enable circuit breaker for unhealthy channels.
|
||||
/// </summary>
|
||||
public bool EnableCircuitBreaker { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before circuit opens.
|
||||
/// </summary>
|
||||
public int CircuitBreakerThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Duration to keep circuit open before allowing retry.
|
||||
/// </summary>
|
||||
public TimeSpan CircuitBreakerDuration { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for channel adapters.
|
||||
/// </summary>
|
||||
public sealed class ChannelAdapterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "ChannelAdapters";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of retry attempts for failed dispatches.
|
||||
/// </summary>
|
||||
public int MaxRetries { get; set; } = 3;
|
||||
|
||||
/// <summary>
|
||||
/// Base delay for exponential backoff between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryBaseDelay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum delay between retries.
|
||||
/// </summary>
|
||||
public TimeSpan RetryMaxDelay { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Timeout for individual dispatch operations.
|
||||
/// </summary>
|
||||
public TimeSpan DispatchTimeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Enable HMAC signing for webhook payloads.
|
||||
/// </summary>
|
||||
public bool EnableHmacSigning { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// User agent string for HTTP requests.
|
||||
/// </summary>
|
||||
public string UserAgent { get; set; } = "StellaOps-Notifier/1.0";
|
||||
|
||||
/// <summary>
|
||||
/// Default concurrency limit per channel type.
|
||||
/// </summary>
|
||||
public int DefaultConcurrency { get; set; } = 10;
|
||||
|
||||
/// <summary>
|
||||
/// Enable circuit breaker for unhealthy channels.
|
||||
/// </summary>
|
||||
public bool EnableCircuitBreaker { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Number of consecutive failures before circuit opens.
|
||||
/// </summary>
|
||||
public int CircuitBreakerThreshold { get; set; } = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Duration to keep circuit open before allowing retry.
|
||||
/// </summary>
|
||||
public TimeSpan CircuitBreakerDuration { get; set; } = TimeSpan.FromMinutes(1);
|
||||
}
|
||||
|
||||
@@ -1,407 +1,407 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack and Teams webhooks with retry policies.
|
||||
/// Handles Slack incoming webhooks and Teams connectors.
|
||||
/// </summary>
|
||||
public sealed class ChatWebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<ChatWebhookChannelAdapter> _logger;
|
||||
|
||||
public ChatWebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
ILogger<ChatWebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
// Routes Slack type to this adapter; Teams uses Custom type
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this adapter can handle the specified channel.
|
||||
/// </summary>
|
||||
public bool CanHandle(NotifyChannel channel)
|
||||
{
|
||||
return channel.Type is NotifyChannelType.Slack or NotifyChannelType.Teams;
|
||||
}
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid webhook URL.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Chat webhook URL is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var isSlack = context.Channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
var payload = isSlack
|
||||
? BuildSlackPayload(context)
|
||||
: BuildTeamsPayload(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, isSlack, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chat webhook delivery {DeliveryId} to {Platform} succeeded on attempt {Attempt}.",
|
||||
context.DeliveryId, isSlack ? "Slack" : "Teams", attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {(isSlack ? "Slack" : "Teams")}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} throttled. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {(isSlack ? "Slack" : "Teams")}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var errorMessage = $"Chat webhook returned {response.StatusCode}: {TruncateError(responseBody)}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} failed: {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Chat webhook delivery {DeliveryId} exhausted all retries.",
|
||||
context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook URL is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var isSlack = channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
|
||||
// Slack/Teams webhooks don't support HEAD, so we just validate the URL format
|
||||
if (isSlack && !uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Slack webhook.");
|
||||
}
|
||||
|
||||
if (!isSlack && !uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("outlook.office.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Teams connector.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"{(isSlack ? "Slack" : "Teams")} webhook URL validated.");
|
||||
}
|
||||
|
||||
private static bool IsSlackWebhook(Uri uri)
|
||||
{
|
||||
return uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildSlackPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var message = new
|
||||
{
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = context.RenderedBody
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Delivery ID:* {context.DeliveryId} | *Trace:* {context.TraceId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string BuildTeamsPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var card = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
weight = "bolder",
|
||||
size = "medium"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.RenderedBody,
|
||||
wrap = true
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = new object[]
|
||||
{
|
||||
new { title = "Delivery ID", value = context.DeliveryId },
|
||||
new { title = "Trace ID", value = context.TraceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(card, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static string TruncateError(string error)
|
||||
{
|
||||
const int maxLength = 200;
|
||||
return error.Length > maxLength ? error[..maxLength] + "..." : error;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
bool isSlack,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["platform"] = isSlack ? "Slack" : "Teams",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack and Teams webhooks with retry policies.
|
||||
/// Handles Slack incoming webhooks and Teams connectors.
|
||||
/// </summary>
|
||||
public sealed class ChatWebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<ChatWebhookChannelAdapter> _logger;
|
||||
|
||||
public ChatWebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
ILogger<ChatWebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
// Routes Slack type to this adapter; Teams uses Custom type
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
/// <summary>
|
||||
/// Determines if this adapter can handle the specified channel.
|
||||
/// </summary>
|
||||
public bool CanHandle(NotifyChannel channel)
|
||||
{
|
||||
return channel.Type is NotifyChannelType.Slack or NotifyChannelType.Teams;
|
||||
}
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid webhook URL.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Chat webhook URL is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var isSlack = context.Channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
var payload = isSlack
|
||||
? BuildSlackPayload(context)
|
||||
: BuildTeamsPayload(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(payload, Encoding.UTF8, "application/json");
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, isSlack, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Chat webhook delivery {DeliveryId} to {Platform} succeeded on attempt {Attempt}.",
|
||||
context.DeliveryId, isSlack ? "Slack" : "Teams", attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {(isSlack ? "Slack" : "Teams")}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} throttled. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {(isSlack ? "Slack" : "Teams")}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var errorMessage = $"Chat webhook returned {response.StatusCode}: {TruncateError(responseBody)}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Chat webhook delivery {DeliveryId} failed: {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Chat webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Chat webhook delivery {DeliveryId} exhausted all retries.",
|
||||
context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook URL is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var isSlack = channel.Type == NotifyChannelType.Slack || IsSlackWebhook(uri);
|
||||
|
||||
// Slack/Teams webhooks don't support HEAD, so we just validate the URL format
|
||||
if (isSlack && !uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Slack webhook.");
|
||||
}
|
||||
|
||||
if (!isSlack && !uri.Host.Contains("webhook.office.com", StringComparison.OrdinalIgnoreCase) &&
|
||||
!uri.Host.Contains("outlook.office.com", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
"Webhook URL doesn't appear to be a Teams connector.");
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"{(isSlack ? "Slack" : "Teams")} webhook URL validated.");
|
||||
}
|
||||
|
||||
private static bool IsSlackWebhook(Uri uri)
|
||||
{
|
||||
return uri.Host.Contains("slack.com", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Host.Contains("hooks.slack.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static string BuildSlackPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var message = new
|
||||
{
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = context.RenderedBody
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "context",
|
||||
elements = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = $"*Delivery ID:* {context.DeliveryId} | *Trace:* {context.TraceId}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static string BuildTeamsPayload(ChannelDispatchContext context)
|
||||
{
|
||||
var card = new
|
||||
{
|
||||
type = "message",
|
||||
attachments = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
contentType = "application/vnd.microsoft.card.adaptive",
|
||||
content = new
|
||||
{
|
||||
type = "AdaptiveCard",
|
||||
version = "1.4",
|
||||
body = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.Subject ?? "StellaOps Notification",
|
||||
weight = "bolder",
|
||||
size = "medium"
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "TextBlock",
|
||||
text = context.RenderedBody,
|
||||
wrap = true
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "FactSet",
|
||||
facts = new object[]
|
||||
{
|
||||
new { title = "Delivery ID", value = context.DeliveryId },
|
||||
new { title = "Trace ID", value = context.TraceId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return JsonSerializer.Serialize(card, new JsonSerializerOptions { WriteIndented = false });
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static string TruncateError(string error)
|
||||
{
|
||||
const int maxLength = 200;
|
||||
return error.Length > maxLength ? error[..maxLength] + "..." : error;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
bool isSlack,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["platform"] = isSlack ? "Slack" : "Teams",
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,190 +1,190 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for CLI-based notification delivery.
|
||||
/// Executes a configured command-line tool with notification payload as input.
|
||||
/// Useful for custom integrations and local testing.
|
||||
/// </summary>
|
||||
public sealed class CliChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<CliChannelAdapter> _logger;
|
||||
private readonly TimeSpan _commandTimeout;
|
||||
|
||||
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var command = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
var (executable, arguments) = ParseCommand(command);
|
||||
if (string.IsNullOrWhiteSpace(executable))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build JSON payload to send via stdin
|
||||
var payload = new
|
||||
{
|
||||
bodyHash = rendered.BodyHash,
|
||||
channel = rendered.ChannelType.ToString(),
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
textBody = rendered.TextBody,
|
||||
format = rendered.Format.ToString(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
channelConfig = new
|
||||
{
|
||||
channelId = channel.ChannelId,
|
||||
name = channel.Name,
|
||||
properties = channel.Config?.Properties
|
||||
}
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_commandTimeout);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
// Add environment variables from channel config
|
||||
if (channel.Config?.Properties is not null)
|
||||
{
|
||||
foreach (var kv in channel.Config.Properties)
|
||||
{
|
||||
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envVar = kv.Key[4..];
|
||||
startInfo.EnvironmentVariables[envVar] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Write payload to stdin
|
||||
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read output streams
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await outputTask.ConfigureAwait(false);
|
||||
var stderr = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CLI command executed successfully. Exit code: 0. Output: {Output}",
|
||||
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
|
||||
|
||||
return ChannelDispatchResult.Ok(process.ExitCode);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
|
||||
process.ExitCode,
|
||||
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
|
||||
|
||||
// Non-zero exit codes are typically not retryable
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"Exit code {process.ExitCode}: {stderr}",
|
||||
shouldRetry: false,
|
||||
httpStatusCode: process.ExitCode);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
|
||||
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string executable, string arguments) ParseCommand(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return (string.Empty, string.Empty);
|
||||
|
||||
// Handle quoted executable paths
|
||||
if (command.StartsWith('"'))
|
||||
{
|
||||
var endQuote = command.IndexOf('"', 1);
|
||||
if (endQuote > 0)
|
||||
{
|
||||
var exe = command[1..endQuote];
|
||||
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
|
||||
return (exe, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple space-separated
|
||||
var spaceIndex = command.IndexOf(' ');
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
|
||||
}
|
||||
|
||||
return (command, string.Empty);
|
||||
}
|
||||
}
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for CLI-based notification delivery.
|
||||
/// Executes a configured command-line tool with notification payload as input.
|
||||
/// Useful for custom integrations and local testing.
|
||||
/// </summary>
|
||||
public sealed class CliChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly ILogger<CliChannelAdapter> _logger;
|
||||
private readonly TimeSpan _commandTimeout;
|
||||
|
||||
public CliChannelAdapter(ILogger<CliChannelAdapter> logger, TimeSpan? commandTimeout = null)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_commandTimeout = commandTimeout ?? TimeSpan.FromSeconds(30);
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Cli;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var command = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("CLI command not configured in endpoint", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Parse command and arguments
|
||||
var (executable, arguments) = ParseCommand(command);
|
||||
if (string.IsNullOrWhiteSpace(executable))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Invalid CLI command format", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build JSON payload to send via stdin
|
||||
var payload = new
|
||||
{
|
||||
bodyHash = rendered.BodyHash,
|
||||
channel = rendered.ChannelType.ToString(),
|
||||
target = rendered.Target,
|
||||
title = rendered.Title,
|
||||
body = rendered.Body,
|
||||
summary = rendered.Summary,
|
||||
textBody = rendered.TextBody,
|
||||
format = rendered.Format.ToString(),
|
||||
locale = rendered.Locale,
|
||||
timestamp = DateTimeOffset.UtcNow.ToString("O"),
|
||||
channelConfig = new
|
||||
{
|
||||
channelId = channel.ChannelId,
|
||||
name = channel.Name,
|
||||
properties = channel.Config?.Properties
|
||||
}
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = false
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
cts.CancelAfter(_commandTimeout);
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = executable,
|
||||
Arguments = arguments,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8
|
||||
};
|
||||
|
||||
// Add environment variables from channel config
|
||||
if (channel.Config?.Properties is not null)
|
||||
{
|
||||
foreach (var kv in channel.Config.Properties)
|
||||
{
|
||||
if (kv.Key.StartsWith("env:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var envVar = kv.Key[4..];
|
||||
startInfo.EnvironmentVariables[envVar] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using var process = new Process { StartInfo = startInfo };
|
||||
|
||||
_logger.LogDebug("Starting CLI command: {Executable} {Arguments}", executable, arguments);
|
||||
|
||||
process.Start();
|
||||
|
||||
// Write payload to stdin
|
||||
await process.StandardInput.WriteAsync(jsonPayload).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
|
||||
// Read output streams
|
||||
var outputTask = process.StandardOutput.ReadToEndAsync(cts.Token);
|
||||
var errorTask = process.StandardError.ReadToEndAsync(cts.Token);
|
||||
|
||||
await process.WaitForExitAsync(cts.Token).ConfigureAwait(false);
|
||||
|
||||
var stdout = await outputTask.ConfigureAwait(false);
|
||||
var stderr = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"CLI command executed successfully. Exit code: 0. Output: {Output}",
|
||||
stdout.Length > 500 ? stdout[..500] + "..." : stdout);
|
||||
|
||||
return ChannelDispatchResult.Ok(process.ExitCode);
|
||||
}
|
||||
|
||||
_logger.LogWarning(
|
||||
"CLI command failed with exit code {ExitCode}. Stderr: {Stderr}",
|
||||
process.ExitCode,
|
||||
stderr.Length > 500 ? stderr[..500] + "..." : stderr);
|
||||
|
||||
// Non-zero exit codes are typically not retryable
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"Exit code {process.ExitCode}: {stderr}",
|
||||
shouldRetry: false,
|
||||
httpStatusCode: process.ExitCode);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning("CLI command timed out after {Timeout}", _commandTimeout);
|
||||
return ChannelDispatchResult.Fail($"Command timeout after {_commandTimeout.TotalSeconds}s", shouldRetry: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CLI command execution failed: {Message}", ex.Message);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: false);
|
||||
}
|
||||
}
|
||||
|
||||
private static (string executable, string arguments) ParseCommand(string command)
|
||||
{
|
||||
command = command.Trim();
|
||||
if (string.IsNullOrEmpty(command))
|
||||
return (string.Empty, string.Empty);
|
||||
|
||||
// Handle quoted executable paths
|
||||
if (command.StartsWith('"'))
|
||||
{
|
||||
var endQuote = command.IndexOf('"', 1);
|
||||
if (endQuote > 0)
|
||||
{
|
||||
var exe = command[1..endQuote];
|
||||
var args = command.Length > endQuote + 1 ? command[(endQuote + 1)..].TrimStart() : string.Empty;
|
||||
return (exe, args);
|
||||
}
|
||||
}
|
||||
|
||||
// Simple space-separated
|
||||
var spaceIndex = command.IndexOf(' ');
|
||||
if (spaceIndex > 0)
|
||||
{
|
||||
return (command[..spaceIndex], command[(spaceIndex + 1)..].TrimStart());
|
||||
}
|
||||
|
||||
return (command, string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,379 +1,379 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for SMTP email dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable
|
||||
{
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _disposed;
|
||||
|
||||
public EmailChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(context.Channel, out var smtpConfig, out var configError))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, configError, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(configError, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var recipients = ParseRecipients(context);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
var error = "No valid recipients configured.";
|
||||
await AuditDispatchAsync(context, false, error, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(error, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateSmtpClient(smtpConfig);
|
||||
using var message = BuildMessage(context, smtpConfig, recipients);
|
||||
|
||||
await client.SendMailAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, smtpConfig, recipients, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Email delivery {DeliveryId} succeeded to {RecipientCount} recipients via {SmtpHost} on attempt {Attempt}.",
|
||||
context.DeliveryId, recipients.Count, smtpConfig.Host, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Sent to {recipients.Count} recipient(s) via {smtpConfig.Host}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (SmtpException ex) when (IsRetryable(ex))
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with retryable SMTP error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (SmtpException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"SMTP error: {ex.StatusCode} - {ex.Message}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} failed with non-retryable SMTP error: {StatusCode}.",
|
||||
context.DeliveryId, ex.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or TimeoutException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Email delivery {DeliveryId} exhausted all {MaxRetries} retries to {SmtpHost}.",
|
||||
context.DeliveryId, maxRetries + 1, smtpConfig.Host);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
ChannelDispatchStatus.NetworkError,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(channel, out var smtpConfig, out var error))
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Unhealthy(error));
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok(
|
||||
$"SMTP configuration validated for {smtpConfig.Host}:{smtpConfig.Port}."));
|
||||
}
|
||||
|
||||
private static bool TryParseSmtpConfig(NotifyChannel channel, out SmtpConfig config, out string error)
|
||||
{
|
||||
config = default;
|
||||
error = string.Empty;
|
||||
|
||||
var props = channel.Config.Properties;
|
||||
if (props is null)
|
||||
{
|
||||
error = "Channel properties are not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpHost", out var host) || string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
error = "SMTP host is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpPort", out var portStr) || !int.TryParse(portStr, out var port))
|
||||
{
|
||||
port = 587;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("fromAddress", out var fromAddress) || string.IsNullOrWhiteSpace(fromAddress))
|
||||
{
|
||||
error = "From address is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
props.TryGetValue("fromName", out var fromName);
|
||||
props.TryGetValue("username", out var username);
|
||||
var enableSsl = !props.TryGetValue("enableSsl", out var sslStr) || !bool.TryParse(sslStr, out var ssl) || ssl;
|
||||
|
||||
config = new SmtpConfig(host, port, fromAddress, fromName, username, channel.Config.SecretRef, enableSsl);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> ParseRecipients(ChannelDispatchContext context)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Channel.Config.Target))
|
||||
{
|
||||
recipients.AddRange(context.Channel.Config.Target
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("recipients", out var metaRecipients) &&
|
||||
!string.IsNullOrWhiteSpace(metaRecipients))
|
||||
{
|
||||
recipients.AddRange(metaRecipients
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
return recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new MailAddress(email);
|
||||
return addr.Address == email.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private SmtpClient CreateSmtpClient(SmtpConfig config)
|
||||
{
|
||||
var client = new SmtpClient(config.Host, config.Port)
|
||||
{
|
||||
EnableSsl = config.EnableSsl,
|
||||
Timeout = (int)_options.DispatchTimeout.TotalMilliseconds,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(config.Username, config.Password);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static MailMessage BuildMessage(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients)
|
||||
{
|
||||
var from = string.IsNullOrWhiteSpace(config.FromName)
|
||||
? new MailAddress(config.FromAddress)
|
||||
: new MailAddress(config.FromAddress, config.FromName);
|
||||
|
||||
var message = new MailMessage
|
||||
{
|
||||
From = from,
|
||||
Subject = context.Subject ?? "StellaOps Notification",
|
||||
Body = context.RenderedBody,
|
||||
IsBodyHtml = context.RenderedBody.Contains("<html", StringComparison.OrdinalIgnoreCase) ||
|
||||
context.RenderedBody.Contains("<body", StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
message.To.Add(recipient);
|
||||
}
|
||||
|
||||
message.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
message.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(SmtpException ex)
|
||||
{
|
||||
return ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.ServiceNotAvailable => true,
|
||||
SmtpStatusCode.MailboxBusy => true,
|
||||
SmtpStatusCode.LocalErrorInProcessing => true,
|
||||
SmtpStatusCode.InsufficientStorage => true,
|
||||
SmtpStatusCode.ServiceClosingTransmissionChannel => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = config.Host,
|
||||
["smtpPort"] = config.Port.ToString(),
|
||||
["recipientCount"] = recipients.Count.ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private readonly record struct SmtpConfig(
|
||||
string Host,
|
||||
int Port,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Username,
|
||||
string? Password,
|
||||
bool EnableSsl);
|
||||
}
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for SMTP email dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class EmailChannelAdapter : IChannelAdapter, IDisposable
|
||||
{
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<EmailChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private bool _disposed;
|
||||
|
||||
public EmailChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<EmailChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Email;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(context.Channel, out var smtpConfig, out var configError))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, configError, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(configError, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var recipients = ParseRecipients(context);
|
||||
if (recipients.Count == 0)
|
||||
{
|
||||
var error = "No valid recipients configured.";
|
||||
await AuditDispatchAsync(context, false, error, null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(error, ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var client = CreateSmtpClient(smtpConfig);
|
||||
using var message = BuildMessage(context, smtpConfig, recipients);
|
||||
|
||||
await client.SendMailAsync(message, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, smtpConfig, recipients, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Email delivery {DeliveryId} succeeded to {RecipientCount} recipients via {SmtpHost} on attempt {Attempt}.",
|
||||
context.DeliveryId, recipients.Count, smtpConfig.Host, attempt);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Sent to {recipients.Count} recipient(s) via {smtpConfig.Host}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (SmtpException ex) when (IsRetryable(ex))
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with retryable SMTP error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (SmtpException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"SMTP error: {ex.StatusCode} - {ex.Message}";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} failed with non-retryable SMTP error: {StatusCode}.",
|
||||
context.DeliveryId, ex.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex) when (ex is System.Net.Sockets.SocketException or TimeoutException)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Email delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Email delivery {DeliveryId} exhausted all {MaxRetries} retries to {SmtpHost}.",
|
||||
context.DeliveryId, maxRetries + 1, smtpConfig.Host);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
ChannelDispatchStatus.NetworkError,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (!TryParseSmtpConfig(channel, out var smtpConfig, out var error))
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Unhealthy(error));
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok(
|
||||
$"SMTP configuration validated for {smtpConfig.Host}:{smtpConfig.Port}."));
|
||||
}
|
||||
|
||||
private static bool TryParseSmtpConfig(NotifyChannel channel, out SmtpConfig config, out string error)
|
||||
{
|
||||
config = default;
|
||||
error = string.Empty;
|
||||
|
||||
var props = channel.Config.Properties;
|
||||
if (props is null)
|
||||
{
|
||||
error = "Channel properties are not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpHost", out var host) || string.IsNullOrWhiteSpace(host))
|
||||
{
|
||||
error = "SMTP host is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("smtpPort", out var portStr) || !int.TryParse(portStr, out var port))
|
||||
{
|
||||
port = 587;
|
||||
}
|
||||
|
||||
if (!props.TryGetValue("fromAddress", out var fromAddress) || string.IsNullOrWhiteSpace(fromAddress))
|
||||
{
|
||||
error = "From address is not configured.";
|
||||
return false;
|
||||
}
|
||||
|
||||
props.TryGetValue("fromName", out var fromName);
|
||||
props.TryGetValue("username", out var username);
|
||||
var enableSsl = !props.TryGetValue("enableSsl", out var sslStr) || !bool.TryParse(sslStr, out var ssl) || ssl;
|
||||
|
||||
config = new SmtpConfig(host, port, fromAddress, fromName, username, channel.Config.SecretRef, enableSsl);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static List<string> ParseRecipients(ChannelDispatchContext context)
|
||||
{
|
||||
var recipients = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Channel.Config.Target))
|
||||
{
|
||||
recipients.AddRange(context.Channel.Config.Target
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("recipients", out var metaRecipients) &&
|
||||
!string.IsNullOrWhiteSpace(metaRecipients))
|
||||
{
|
||||
recipients.AddRange(metaRecipients
|
||||
.Split([',', ';'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(IsValidEmail));
|
||||
}
|
||||
|
||||
return recipients.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try
|
||||
{
|
||||
var addr = new MailAddress(email);
|
||||
return addr.Address == email.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private SmtpClient CreateSmtpClient(SmtpConfig config)
|
||||
{
|
||||
var client = new SmtpClient(config.Host, config.Port)
|
||||
{
|
||||
EnableSsl = config.EnableSsl,
|
||||
Timeout = (int)_options.DispatchTimeout.TotalMilliseconds,
|
||||
DeliveryMethod = SmtpDeliveryMethod.Network
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.Username) && !string.IsNullOrWhiteSpace(config.Password))
|
||||
{
|
||||
client.Credentials = new NetworkCredential(config.Username, config.Password);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static MailMessage BuildMessage(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients)
|
||||
{
|
||||
var from = string.IsNullOrWhiteSpace(config.FromName)
|
||||
? new MailAddress(config.FromAddress)
|
||||
: new MailAddress(config.FromAddress, config.FromName);
|
||||
|
||||
var message = new MailMessage
|
||||
{
|
||||
From = from,
|
||||
Subject = context.Subject ?? "StellaOps Notification",
|
||||
Body = context.RenderedBody,
|
||||
IsBodyHtml = context.RenderedBody.Contains("<html", StringComparison.OrdinalIgnoreCase) ||
|
||||
context.RenderedBody.Contains("<body", StringComparison.OrdinalIgnoreCase)
|
||||
};
|
||||
|
||||
foreach (var recipient in recipients)
|
||||
{
|
||||
message.To.Add(recipient);
|
||||
}
|
||||
|
||||
message.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
message.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(SmtpException ex)
|
||||
{
|
||||
return ex.StatusCode switch
|
||||
{
|
||||
SmtpStatusCode.ServiceNotAvailable => true,
|
||||
SmtpStatusCode.MailboxBusy => true,
|
||||
SmtpStatusCode.LocalErrorInProcessing => true,
|
||||
SmtpStatusCode.InsufficientStorage => true,
|
||||
SmtpStatusCode.ServiceClosingTransmissionChannel => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
SmtpConfig config,
|
||||
List<string> recipients,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["smtpHost"] = config.Host,
|
||||
["smtpPort"] = config.Port.ToString(),
|
||||
["recipientCount"] = recipients.Count.ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private readonly record struct SmtpConfig(
|
||||
string Host,
|
||||
int Port,
|
||||
string FromAddress,
|
||||
string? FromName,
|
||||
string? Username,
|
||||
string? Password,
|
||||
bool EnableSsl);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +1,197 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel adapters to dispatch notifications.
|
||||
/// </summary>
|
||||
public interface IChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type handled by this adapter.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a notification delivery through the channel.
|
||||
/// </summary>
|
||||
Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks channel health/connectivity.
|
||||
/// </summary>
|
||||
Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for dispatching a notification through a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchContext(
|
||||
string DeliveryId,
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
NotifyDelivery Delivery,
|
||||
string RenderedBody,
|
||||
string? Subject,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required ChannelDispatchStatus Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? ExternalId { get; init; }
|
||||
public int? HttpStatusCode { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
public Exception? Exception { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Succeeded(
|
||||
string? externalId = null,
|
||||
string? message = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
ExternalId = externalId,
|
||||
Message = message ?? "Delivery dispatched successfully.",
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a simple success result (legacy helper).
|
||||
/// </summary>
|
||||
public static ChannelDispatchResult Ok(
|
||||
int? httpStatusCode = null,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = message ?? "ok",
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Failed(
|
||||
string message,
|
||||
ChannelDispatchStatus status = ChannelDispatchStatus.Failed,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a simplified failure result (legacy helper).
|
||||
/// </summary>
|
||||
public static ChannelDispatchResult Fail(
|
||||
string message,
|
||||
bool shouldRetry = false,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var status = shouldRetry ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.Failed;
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
public static ChannelDispatchResult Throttled(
|
||||
string message,
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var meta = metadata is not null
|
||||
? new Dictionary<string, string>(metadata)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
if (retryAfter.HasValue)
|
||||
{
|
||||
meta["retryAfterSeconds"] = retryAfter.Value.TotalSeconds.ToString("F0");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = ChannelDispatchStatus.Throttled,
|
||||
Message = message,
|
||||
HttpStatusCode = 429,
|
||||
Metadata = meta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch attempt status.
|
||||
/// </summary>
|
||||
public enum ChannelDispatchStatus
|
||||
{
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
InvalidConfiguration,
|
||||
Timeout,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel health check.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthCheckResult
|
||||
{
|
||||
public required bool Healthy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public TimeSpan? Latency { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public static ChannelHealthCheckResult Ok(string? message = null, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "healthy",
|
||||
Message = message ?? "Channel is operational.",
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Degraded(string message, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "degraded",
|
||||
Message = message,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Unhealthy(string message) => new()
|
||||
{
|
||||
Healthy = false,
|
||||
Status = "unhealthy",
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Contract implemented by channel adapters to dispatch notifications.
|
||||
/// </summary>
|
||||
public interface IChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// Channel type handled by this adapter.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a notification delivery through the channel.
|
||||
/// </summary>
|
||||
Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Checks channel health/connectivity.
|
||||
/// </summary>
|
||||
Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Context for dispatching a notification through a channel.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchContext(
|
||||
string DeliveryId,
|
||||
string TenantId,
|
||||
NotifyChannel Channel,
|
||||
NotifyDelivery Delivery,
|
||||
string RenderedBody,
|
||||
string? Subject,
|
||||
IReadOnlyDictionary<string, string> Metadata,
|
||||
DateTimeOffset Timestamp,
|
||||
string TraceId);
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel dispatch attempt.
|
||||
/// </summary>
|
||||
public sealed record ChannelDispatchResult
|
||||
{
|
||||
public required bool Success { get; init; }
|
||||
public required ChannelDispatchStatus Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public string? ExternalId { get; init; }
|
||||
public int? HttpStatusCode { get; init; }
|
||||
public TimeSpan? Duration { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
public Exception? Exception { get; init; }
|
||||
|
||||
public static ChannelDispatchResult Succeeded(
|
||||
string? externalId = null,
|
||||
string? message = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
ExternalId = externalId,
|
||||
Message = message ?? "Delivery dispatched successfully.",
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a simple success result (legacy helper).
|
||||
/// </summary>
|
||||
public static ChannelDispatchResult Ok(
|
||||
int? httpStatusCode = null,
|
||||
string? message = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = true,
|
||||
Status = ChannelDispatchStatus.Sent,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Message = message ?? "ok",
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
public static ChannelDispatchResult Failed(
|
||||
string message,
|
||||
ChannelDispatchStatus status = ChannelDispatchStatus.Failed,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
TimeSpan? duration = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null) => new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Duration = duration,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a simplified failure result (legacy helper).
|
||||
/// </summary>
|
||||
public static ChannelDispatchResult Fail(
|
||||
string message,
|
||||
bool shouldRetry = false,
|
||||
int? httpStatusCode = null,
|
||||
Exception? exception = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var status = shouldRetry ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.Failed;
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = status,
|
||||
Message = message,
|
||||
HttpStatusCode = httpStatusCode,
|
||||
Exception = exception,
|
||||
Metadata = metadata ?? new Dictionary<string, string>()
|
||||
};
|
||||
}
|
||||
|
||||
public static ChannelDispatchResult Throttled(
|
||||
string message,
|
||||
TimeSpan? retryAfter = null,
|
||||
IReadOnlyDictionary<string, string>? metadata = null)
|
||||
{
|
||||
var meta = metadata is not null
|
||||
? new Dictionary<string, string>(metadata)
|
||||
: new Dictionary<string, string>();
|
||||
|
||||
if (retryAfter.HasValue)
|
||||
{
|
||||
meta["retryAfterSeconds"] = retryAfter.Value.TotalSeconds.ToString("F0");
|
||||
}
|
||||
|
||||
return new()
|
||||
{
|
||||
Success = false,
|
||||
Status = ChannelDispatchStatus.Throttled,
|
||||
Message = message,
|
||||
HttpStatusCode = 429,
|
||||
Metadata = meta
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatch attempt status.
|
||||
/// </summary>
|
||||
public enum ChannelDispatchStatus
|
||||
{
|
||||
Sent,
|
||||
Failed,
|
||||
Throttled,
|
||||
InvalidConfiguration,
|
||||
Timeout,
|
||||
NetworkError
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a channel health check.
|
||||
/// </summary>
|
||||
public sealed record ChannelHealthCheckResult
|
||||
{
|
||||
public required bool Healthy { get; init; }
|
||||
public required string Status { get; init; }
|
||||
public string? Message { get; init; }
|
||||
public TimeSpan? Latency { get; init; }
|
||||
public IReadOnlyDictionary<string, string> Metadata { get; init; } = new Dictionary<string, string>();
|
||||
|
||||
public static ChannelHealthCheckResult Ok(string? message = null, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "healthy",
|
||||
Message = message ?? "Channel is operational.",
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Degraded(string message, TimeSpan? latency = null) => new()
|
||||
{
|
||||
Healthy = true,
|
||||
Status = "degraded",
|
||||
Message = message,
|
||||
Latency = latency
|
||||
};
|
||||
|
||||
public static ChannelHealthCheckResult Unhealthy(string message) => new()
|
||||
{
|
||||
Healthy = false,
|
||||
Status = "unhealthy",
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Sends rendered notifications through a specific channel type.
|
||||
/// </summary>
|
||||
public interface INotifyChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type this adapter handles.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a rendered notification through the channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel configuration.</param>
|
||||
/// <param name="rendered">The rendered notification content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The dispatch result with status and any error details.</returns>
|
||||
Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
// Note: ChannelDispatchResult is defined in IChannelAdapter.cs
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Sends rendered notifications through a specific channel type.
|
||||
/// </summary>
|
||||
public interface INotifyChannelAdapter
|
||||
{
|
||||
/// <summary>
|
||||
/// The channel type this adapter handles.
|
||||
/// </summary>
|
||||
NotifyChannelType ChannelType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Sends a rendered notification through the channel.
|
||||
/// </summary>
|
||||
/// <param name="channel">The channel configuration.</param>
|
||||
/// <param name="rendered">The rendered notification content.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The dispatch result with status and any error details.</returns>
|
||||
Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
// Note: ChannelDispatchResult is defined in IChannelAdapter.cs
|
||||
|
||||
@@ -1,484 +1,484 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app notifications (inbox/CLI).
|
||||
/// Stores notifications in-memory for retrieval by users/services.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<InAppNotification>> _inboxes = new();
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly InAppChannelOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InAppChannelAdapter> _logger;
|
||||
|
||||
public InAppChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<InAppChannelOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InAppChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? new InAppChannelOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InApp;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetTargetUserId(context);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "No target user ID specified.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Target user ID is required for in-app notifications.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}"[..20],
|
||||
DeliveryId = context.DeliveryId,
|
||||
TenantId = context.TenantId,
|
||||
UserId = userId,
|
||||
Title = context.Subject ?? "Notification",
|
||||
Body = context.RenderedBody,
|
||||
Priority = GetPriority(context),
|
||||
Category = GetCategory(context),
|
||||
IncidentId = context.Metadata.GetValueOrDefault("incidentId"),
|
||||
ActionUrl = context.Metadata.GetValueOrDefault("actionUrl"),
|
||||
AckUrl = context.Metadata.GetValueOrDefault("ackUrl"),
|
||||
Metadata = new Dictionary<string, string>(context.Metadata),
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = _timeProvider.GetUtcNow() + _options.NotificationTtl,
|
||||
Status = InAppNotificationStatus.Unread
|
||||
};
|
||||
|
||||
// Store in inbox
|
||||
var inboxKey = BuildInboxKey(context.TenantId, userId);
|
||||
var inbox = _inboxes.GetOrAdd(inboxKey, _ => new ConcurrentQueue<InAppNotification>());
|
||||
inbox.Enqueue(notification);
|
||||
|
||||
// Enforce max notifications per inbox
|
||||
while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _))
|
||||
{
|
||||
// Remove oldest
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["notificationId"] = notification.NotificationId,
|
||||
["userId"] = userId,
|
||||
["inboxSize"] = inbox.Count.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app notification {NotificationId} delivered to user {UserId} inbox for tenant {TenantId}.",
|
||||
notification.NotificationId, userId, context.TenantId);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: notification.NotificationId,
|
||||
message: $"Delivered to inbox for user {userId}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, ex.Message, null, cancellationToken);
|
||||
|
||||
_logger.LogError(ex, "In-app notification dispatch failed for delivery {DeliveryId}.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
ex.Message,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok("In-app channel operational."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetUnreadNotifications(string tenantId, string userId, int limit = 50)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => n.Status == InAppNotificationStatus.Unread && (!n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetNotifications(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 100,
|
||||
bool includeRead = true,
|
||||
bool includeExpired = false)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => (includeRead || n.Status == InAppNotificationStatus.Unread) &&
|
||||
(includeExpired || !n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a notification as read.
|
||||
/// </summary>
|
||||
public bool MarkAsRead(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = _timeProvider.GetUtcNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all notifications as read for a user.
|
||||
/// </summary>
|
||||
public int MarkAllAsRead(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var notification in inbox.Where(n => n.Status == InAppNotificationStatus.Unread))
|
||||
{
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = now;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a notification.
|
||||
/// </summary>
|
||||
public bool DeleteNotification(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ConcurrentQueue doesn't support removal, so mark as deleted
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Deleted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread count for a user.
|
||||
/// </summary>
|
||||
public int GetUnreadCount(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox.Count(n => n.Status == InAppNotificationStatus.Unread &&
|
||||
(!n.ExpiresAt.HasValue || n.ExpiresAt > now));
|
||||
}
|
||||
|
||||
private static string GetTargetUserId(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("targetUserId", out var userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("userId", out userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static InAppNotificationPriority GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("priority", out var priority) ||
|
||||
context.Metadata.TryGetValue("severity", out priority))
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" or "urgent" => InAppNotificationPriority.Urgent,
|
||||
"high" => InAppNotificationPriority.High,
|
||||
"medium" => InAppNotificationPriority.Normal,
|
||||
"low" => InAppNotificationPriority.Low,
|
||||
_ => InAppNotificationPriority.Normal
|
||||
};
|
||||
}
|
||||
return InAppNotificationPriority.Normal;
|
||||
}
|
||||
|
||||
private static string GetCategory(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("category", out var category) && !string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("eventKind", out var eventKind) && !string.IsNullOrWhiteSpace(eventKind))
|
||||
{
|
||||
return eventKind;
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static string BuildInboxKey(string tenantId, string userId) =>
|
||||
$"{tenantId}:{userId}";
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "InApp",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for in-app channel adapter.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "InAppChannel";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum notifications to keep per user inbox.
|
||||
/// </summary>
|
||||
public int MaxNotificationsPerInbox { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for notifications.
|
||||
/// </summary>
|
||||
public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An in-app notification stored in user's inbox.
|
||||
/// </summary>
|
||||
public sealed class InAppNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique notification ID.
|
||||
/// </summary>
|
||||
public required string NotificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original delivery ID.
|
||||
/// </summary>
|
||||
public required string DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target user ID.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification body/content.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority level.
|
||||
/// </summary>
|
||||
public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Notification category.
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related incident ID.
|
||||
/// </summary>
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for main action.
|
||||
/// </summary>
|
||||
public string? ActionUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for acknowledgment action.
|
||||
/// </summary>
|
||||
public string? AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread;
|
||||
|
||||
/// <summary>
|
||||
/// When read.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification status.
|
||||
/// </summary>
|
||||
public enum InAppNotificationStatus
|
||||
{
|
||||
Unread,
|
||||
Read,
|
||||
Actioned,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification priority.
|
||||
/// </summary>
|
||||
public enum InAppNotificationPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent
|
||||
}
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app notifications (inbox/CLI).
|
||||
/// Stores notifications in-memory for retrieval by users/services.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ConcurrentQueue<InAppNotification>> _inboxes = new();
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly InAppChannelOptions _options;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<InAppChannelAdapter> _logger;
|
||||
|
||||
public InAppChannelAdapter(
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<InAppChannelOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<InAppChannelAdapter> logger)
|
||||
{
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? new InAppChannelOptions();
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InApp;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
var userId = GetTargetUserId(context);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "No target user ID specified.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Target user ID is required for in-app notifications.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var notification = new InAppNotification
|
||||
{
|
||||
NotificationId = $"notif-{Guid.NewGuid():N}"[..20],
|
||||
DeliveryId = context.DeliveryId,
|
||||
TenantId = context.TenantId,
|
||||
UserId = userId,
|
||||
Title = context.Subject ?? "Notification",
|
||||
Body = context.RenderedBody,
|
||||
Priority = GetPriority(context),
|
||||
Category = GetCategory(context),
|
||||
IncidentId = context.Metadata.GetValueOrDefault("incidentId"),
|
||||
ActionUrl = context.Metadata.GetValueOrDefault("actionUrl"),
|
||||
AckUrl = context.Metadata.GetValueOrDefault("ackUrl"),
|
||||
Metadata = new Dictionary<string, string>(context.Metadata),
|
||||
CreatedAt = _timeProvider.GetUtcNow(),
|
||||
ExpiresAt = _timeProvider.GetUtcNow() + _options.NotificationTtl,
|
||||
Status = InAppNotificationStatus.Unread
|
||||
};
|
||||
|
||||
// Store in inbox
|
||||
var inboxKey = BuildInboxKey(context.TenantId, userId);
|
||||
var inbox = _inboxes.GetOrAdd(inboxKey, _ => new ConcurrentQueue<InAppNotification>());
|
||||
inbox.Enqueue(notification);
|
||||
|
||||
// Enforce max notifications per inbox
|
||||
while (inbox.Count > _options.MaxNotificationsPerInbox && inbox.TryDequeue(out _))
|
||||
{
|
||||
// Remove oldest
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var metadata = new Dictionary<string, string>
|
||||
{
|
||||
["notificationId"] = notification.NotificationId,
|
||||
["userId"] = userId,
|
||||
["inboxSize"] = inbox.Count.ToString()
|
||||
};
|
||||
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app notification {NotificationId} delivered to user {UserId} inbox for tenant {TenantId}.",
|
||||
notification.NotificationId, userId, context.TenantId);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
externalId: notification.NotificationId,
|
||||
message: $"Delivered to inbox for user {userId}",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
await AuditDispatchAsync(context, false, ex.Message, null, cancellationToken);
|
||||
|
||||
_logger.LogError(ex, "In-app notification dispatch failed for delivery {DeliveryId}.", context.DeliveryId);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
ex.Message,
|
||||
ChannelDispatchStatus.Failed,
|
||||
exception: ex,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return Task.FromResult(ChannelHealthCheckResult.Degraded("Channel is disabled."));
|
||||
}
|
||||
|
||||
return Task.FromResult(ChannelHealthCheckResult.Ok("In-app channel operational."));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetUnreadNotifications(string tenantId, string userId, int limit = 50)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => n.Status == InAppNotificationStatus.Unread && (!n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all notifications for a user.
|
||||
/// </summary>
|
||||
public IReadOnlyList<InAppNotification> GetNotifications(
|
||||
string tenantId,
|
||||
string userId,
|
||||
int limit = 100,
|
||||
bool includeRead = true,
|
||||
bool includeExpired = false)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox
|
||||
.Where(n => (includeRead || n.Status == InAppNotificationStatus.Unread) &&
|
||||
(includeExpired || !n.ExpiresAt.HasValue || n.ExpiresAt > now))
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a notification as read.
|
||||
/// </summary>
|
||||
public bool MarkAsRead(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = _timeProvider.GetUtcNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks all notifications as read for a user.
|
||||
/// </summary>
|
||||
public int MarkAllAsRead(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var notification in inbox.Where(n => n.Status == InAppNotificationStatus.Unread))
|
||||
{
|
||||
notification.Status = InAppNotificationStatus.Read;
|
||||
notification.ReadAt = now;
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a notification.
|
||||
/// </summary>
|
||||
public bool DeleteNotification(string tenantId, string userId, string notificationId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// ConcurrentQueue doesn't support removal, so mark as deleted
|
||||
var notification = inbox.FirstOrDefault(n => n.NotificationId == notificationId);
|
||||
if (notification is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
notification.Status = InAppNotificationStatus.Deleted;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets unread count for a user.
|
||||
/// </summary>
|
||||
public int GetUnreadCount(string tenantId, string userId)
|
||||
{
|
||||
var inboxKey = BuildInboxKey(tenantId, userId);
|
||||
if (!_inboxes.TryGetValue(inboxKey, out var inbox))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return inbox.Count(n => n.Status == InAppNotificationStatus.Unread &&
|
||||
(!n.ExpiresAt.HasValue || n.ExpiresAt > now));
|
||||
}
|
||||
|
||||
private static string GetTargetUserId(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("targetUserId", out var userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("userId", out userId) && !string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static InAppNotificationPriority GetPriority(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("priority", out var priority) ||
|
||||
context.Metadata.TryGetValue("severity", out priority))
|
||||
{
|
||||
return priority.ToLowerInvariant() switch
|
||||
{
|
||||
"critical" or "urgent" => InAppNotificationPriority.Urgent,
|
||||
"high" => InAppNotificationPriority.High,
|
||||
"medium" => InAppNotificationPriority.Normal,
|
||||
"low" => InAppNotificationPriority.Low,
|
||||
_ => InAppNotificationPriority.Normal
|
||||
};
|
||||
}
|
||||
return InAppNotificationPriority.Normal;
|
||||
}
|
||||
|
||||
private static string GetCategory(ChannelDispatchContext context)
|
||||
{
|
||||
if (context.Metadata.TryGetValue("category", out var category) && !string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return category;
|
||||
}
|
||||
|
||||
if (context.Metadata.TryGetValue("eventKind", out var eventKind) && !string.IsNullOrWhiteSpace(eventKind))
|
||||
{
|
||||
return eventKind;
|
||||
}
|
||||
|
||||
return "general";
|
||||
}
|
||||
|
||||
private static string BuildInboxKey(string tenantId, string userId) =>
|
||||
$"{tenantId}:{userId}";
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = "InApp",
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Options for in-app channel adapter.
|
||||
/// </summary>
|
||||
public sealed class InAppChannelOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name.
|
||||
/// </summary>
|
||||
public const string SectionName = "InAppChannel";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum notifications to keep per user inbox.
|
||||
/// </summary>
|
||||
public int MaxNotificationsPerInbox { get; set; } = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Time-to-live for notifications.
|
||||
/// </summary>
|
||||
public TimeSpan NotificationTtl { get; set; } = TimeSpan.FromDays(30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An in-app notification stored in user's inbox.
|
||||
/// </summary>
|
||||
public sealed class InAppNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique notification ID.
|
||||
/// </summary>
|
||||
public required string NotificationId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Original delivery ID.
|
||||
/// </summary>
|
||||
public required string DeliveryId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Tenant ID.
|
||||
/// </summary>
|
||||
public required string TenantId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Target user ID.
|
||||
/// </summary>
|
||||
public required string UserId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification title.
|
||||
/// </summary>
|
||||
public required string Title { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Notification body/content.
|
||||
/// </summary>
|
||||
public string? Body { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Priority level.
|
||||
/// </summary>
|
||||
public InAppNotificationPriority Priority { get; init; } = InAppNotificationPriority.Normal;
|
||||
|
||||
/// <summary>
|
||||
/// Notification category.
|
||||
/// </summary>
|
||||
public required string Category { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Related incident ID.
|
||||
/// </summary>
|
||||
public string? IncidentId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for main action.
|
||||
/// </summary>
|
||||
public string? ActionUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// URL for acknowledgment action.
|
||||
/// </summary>
|
||||
public string? AckUrl { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional metadata.
|
||||
/// </summary>
|
||||
public Dictionary<string, string> Metadata { get; init; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// When created.
|
||||
/// </summary>
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// When expires.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current status.
|
||||
/// </summary>
|
||||
public InAppNotificationStatus Status { get; set; } = InAppNotificationStatus.Unread;
|
||||
|
||||
/// <summary>
|
||||
/// When read.
|
||||
/// </summary>
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification status.
|
||||
/// </summary>
|
||||
public enum InAppNotificationStatus
|
||||
{
|
||||
Unread,
|
||||
Read,
|
||||
Actioned,
|
||||
Deleted
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app notification priority.
|
||||
/// </summary>
|
||||
public enum InAppNotificationPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Urgent
|
||||
}
|
||||
|
||||
|
||||
@@ -1,156 +1,156 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app inbox notifications.
|
||||
/// Stores notifications in the database for users to retrieve via API or WebSocket.
|
||||
/// </summary>
|
||||
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly IInAppInboxStore _inboxStore;
|
||||
private readonly ILogger<InAppInboxChannelAdapter> _logger;
|
||||
|
||||
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
|
||||
{
|
||||
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var userId = rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
// Try to get from channel config
|
||||
userId = channel.Config?.Target;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
|
||||
}
|
||||
|
||||
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
|
||||
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var inboxMessage = new InAppInboxMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Title = rendered.Title ?? "Notification",
|
||||
Body = rendered.Body ?? string.Empty,
|
||||
Summary = rendered.Summary,
|
||||
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
|
||||
Priority = DeterminePriority(rendered),
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DetermineExpiry(channel),
|
||||
SourceChannel = channel.ChannelId,
|
||||
DeliveryId = messageId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
|
||||
userId,
|
||||
inboxMessage.MessageId);
|
||||
|
||||
return ChannelDispatchResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Critical;
|
||||
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.High;
|
||||
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Normal;
|
||||
|
||||
return InAppInboxPriority.Low;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
|
||||
{
|
||||
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
|
||||
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
|
||||
{
|
||||
return DateTimeOffset.UtcNow.AddHours(ttlHours);
|
||||
}
|
||||
|
||||
// Default 30 day expiry
|
||||
return DateTimeOffset.UtcNow.AddDays(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for in-app inbox messages.
|
||||
/// </summary>
|
||||
public interface IInAppInboxStore
|
||||
{
|
||||
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model.
|
||||
/// </summary>
|
||||
public sealed record InAppInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public InAppInboxPriority Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for in-app inbox messages.
|
||||
/// </summary>
|
||||
public enum InAppInboxPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for in-app inbox notifications.
|
||||
/// Stores notifications in the database for users to retrieve via API or WebSocket.
|
||||
/// </summary>
|
||||
public sealed class InAppInboxChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly IInAppInboxStore _inboxStore;
|
||||
private readonly ILogger<InAppInboxChannelAdapter> _logger;
|
||||
|
||||
public InAppInboxChannelAdapter(IInAppInboxStore inboxStore, ILogger<InAppInboxChannelAdapter> logger)
|
||||
{
|
||||
_inboxStore = inboxStore ?? throw new ArgumentNullException(nameof(inboxStore));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.InAppInbox;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var userId = rendered.Target;
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
// Try to get from channel config
|
||||
userId = channel.Config?.Target;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Target user ID not specified", shouldRetry: false);
|
||||
}
|
||||
|
||||
var tenantId = channel.Config?.Properties.GetValueOrDefault("tenantId") ?? channel.TenantId;
|
||||
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var inboxMessage = new InAppInboxMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
TenantId = tenantId,
|
||||
UserId = userId,
|
||||
Title = rendered.Title ?? "Notification",
|
||||
Body = rendered.Body ?? string.Empty,
|
||||
Summary = rendered.Summary,
|
||||
Category = channel.Config?.Properties.GetValueOrDefault("category") ?? "general",
|
||||
Priority = DeterminePriority(rendered),
|
||||
Metadata = null,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
ExpiresAt = DetermineExpiry(channel),
|
||||
SourceChannel = channel.ChannelId,
|
||||
DeliveryId = messageId
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _inboxStore.StoreAsync(inboxMessage, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogInformation(
|
||||
"In-app inbox message stored for user {UserId}. MessageId: {MessageId}",
|
||||
userId,
|
||||
inboxMessage.MessageId);
|
||||
|
||||
return ChannelDispatchResult.Ok();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to store in-app inbox message for user {UserId}", userId);
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
}
|
||||
|
||||
private static InAppInboxPriority DeterminePriority(NotifyDeliveryRendered rendered)
|
||||
{
|
||||
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("urgent", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Critical;
|
||||
|
||||
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true ||
|
||||
rendered.Title?.Contains("important", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.High;
|
||||
|
||||
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
|
||||
return InAppInboxPriority.Normal;
|
||||
|
||||
return InAppInboxPriority.Low;
|
||||
}
|
||||
|
||||
private static DateTimeOffset? DetermineExpiry(NotifyChannel channel)
|
||||
{
|
||||
var ttlStr = channel.Config?.Properties.GetValueOrDefault("ttl");
|
||||
if (!string.IsNullOrEmpty(ttlStr) && int.TryParse(ttlStr, out var ttlHours))
|
||||
{
|
||||
return DateTimeOffset.UtcNow.AddHours(ttlHours);
|
||||
}
|
||||
|
||||
// Default 30 day expiry
|
||||
return DateTimeOffset.UtcNow.AddDays(30);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Storage interface for in-app inbox messages.
|
||||
/// </summary>
|
||||
public interface IInAppInboxStore
|
||||
{
|
||||
Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(string tenantId, string userId, int limit = 50, CancellationToken cancellationToken = default);
|
||||
Task<InAppInboxMessage?> GetAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default);
|
||||
Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-app inbox message model.
|
||||
/// </summary>
|
||||
public sealed record InAppInboxMessage
|
||||
{
|
||||
public required string MessageId { get; init; }
|
||||
public required string TenantId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string Body { get; init; }
|
||||
public string? Summary { get; init; }
|
||||
public required string Category { get; init; }
|
||||
public InAppInboxPriority Priority { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Metadata { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? ExpiresAt { get; init; }
|
||||
public DateTimeOffset? ReadAt { get; set; }
|
||||
public bool IsRead => ReadAt.HasValue;
|
||||
public string? SourceChannel { get; init; }
|
||||
public string? DeliveryId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Priority levels for in-app inbox messages.
|
||||
/// </summary>
|
||||
public enum InAppInboxPriority
|
||||
{
|
||||
Low,
|
||||
Normal,
|
||||
High,
|
||||
Critical
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,107 +1,107 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack webhook delivery.
|
||||
/// </summary>
|
||||
public sealed class SlackChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<SlackChannelAdapter> _logger;
|
||||
|
||||
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build Slack message payload
|
||||
var slackPayload = new
|
||||
{
|
||||
channel = channel.Config?.Target,
|
||||
text = rendered.Title,
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Slack delivery to channel {Target} succeeded.",
|
||||
channel.Config?.Target ?? "(default)");
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
shouldRetry: shouldRetry,
|
||||
httpStatusCode: statusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slack delivery failed with network error.");
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Slack delivery timed out.");
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using StellaOps.Notify.Models;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for Slack webhook delivery.
|
||||
/// </summary>
|
||||
public sealed class SlackChannelAdapter : INotifyChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<SlackChannelAdapter> _logger;
|
||||
|
||||
public SlackChannelAdapter(HttpClient httpClient, ILogger<SlackChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Slack;
|
||||
|
||||
public async Task<ChannelDispatchResult> SendAsync(
|
||||
NotifyChannel channel,
|
||||
NotifyDeliveryRendered rendered,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
ArgumentNullException.ThrowIfNull(rendered);
|
||||
|
||||
var endpoint = channel.Config?.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint))
|
||||
{
|
||||
return ChannelDispatchResult.Fail("Slack webhook URL not configured", shouldRetry: false);
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelDispatchResult.Fail($"Invalid Slack webhook URL: {endpoint}", shouldRetry: false);
|
||||
}
|
||||
|
||||
// Build Slack message payload
|
||||
var slackPayload = new
|
||||
{
|
||||
channel = channel.Config?.Target,
|
||||
text = rendered.Title,
|
||||
blocks = new object[]
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "section",
|
||||
text = new
|
||||
{
|
||||
type = "mrkdwn",
|
||||
text = rendered.Body
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = JsonContent.Create(slackPayload, options: new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Slack delivery to channel {Target} succeeded.",
|
||||
channel.Config?.Target ?? "(default)");
|
||||
return ChannelDispatchResult.Ok(statusCode);
|
||||
}
|
||||
|
||||
var shouldRetry = statusCode >= 500 || statusCode == 429;
|
||||
_logger.LogWarning(
|
||||
"Slack delivery failed with status {StatusCode}. Retry: {ShouldRetry}.",
|
||||
statusCode,
|
||||
shouldRetry);
|
||||
|
||||
return ChannelDispatchResult.Fail(
|
||||
$"HTTP {statusCode}",
|
||||
shouldRetry: shouldRetry,
|
||||
httpStatusCode: statusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Slack delivery failed with network error.");
|
||||
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
|
||||
}
|
||||
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Slack delivery timed out.");
|
||||
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,353 +1,353 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for generic HTTP webhook dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<WebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid endpoint configuration.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Webhook endpoint is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(context, uri);
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, response, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery {DeliveryId} succeeded to {Endpoint} on attempt {Attempt} in {Duration}ms.",
|
||||
context.DeliveryId, endpoint, attempt, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {uri.Host} with status {response.StatusCode}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited by endpoint.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} throttled by {Endpoint}. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, endpoint, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {uri.Host}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"Webhook returned non-retryable status {response.StatusCode}.";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Webhook delivery {DeliveryId} exhausted all {MaxRetries} retries to {Endpoint}.",
|
||||
context.DeliveryId, maxRetries + 1, endpoint);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook endpoint is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"Endpoint responded with {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
$"Endpoint returned {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogDebug(ex, "Webhook health check failed for channel {ChannelId}.", channel.ChannelId);
|
||||
return ChannelHealthCheckResult.Unhealthy($"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(ChannelDispatchContext context, Uri uri)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(context.RenderedBody, Encoding.UTF8, "application/json");
|
||||
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
request.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
request.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
request.Headers.Add("X-StellaOps-Timestamp", context.Timestamp.ToString("O"));
|
||||
|
||||
if (_options.EnableHmacSigning && TryGetHmacSecret(context.Channel, out var secret))
|
||||
{
|
||||
var signature = ComputeHmacSignature(context.RenderedBody, secret);
|
||||
request.Headers.Add("X-StellaOps-Signature", $"sha256={signature}");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static bool TryGetHmacSecret(NotifyChannel channel, out string secret)
|
||||
{
|
||||
secret = string.Empty;
|
||||
if (channel.Config.Properties.TryGetValue("hmacSecret", out var s) && !string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
secret = s;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
HttpResponseMessage response,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = context.Channel.Config.Endpoint ?? string.Empty,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using StellaOps.Notify.Models;
|
||||
using StellaOps.Notifier.Worker.Storage;
|
||||
using StellaOps.Notifier.Worker.Options;
|
||||
|
||||
namespace StellaOps.Notifier.Worker.Channels;
|
||||
|
||||
/// <summary>
|
||||
/// Channel adapter for generic HTTP webhook dispatch with retry policies.
|
||||
/// </summary>
|
||||
public sealed class WebhookChannelAdapter : IChannelAdapter
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly INotifyAuditRepository _auditRepository;
|
||||
private readonly ChannelAdapterOptions _options;
|
||||
private readonly ILogger<WebhookChannelAdapter> _logger;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public WebhookChannelAdapter(
|
||||
HttpClient httpClient,
|
||||
INotifyAuditRepository auditRepository,
|
||||
IOptions<ChannelAdapterOptions> options,
|
||||
TimeProvider timeProvider,
|
||||
ILogger<WebhookChannelAdapter> logger)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
_auditRepository = auditRepository ?? throw new ArgumentNullException(nameof(auditRepository));
|
||||
_options = options?.Value ?? throw new ArgumentNullException(nameof(options));
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
|
||||
|
||||
public async Task<ChannelDispatchResult> DispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
var endpoint = context.Channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
await AuditDispatchAsync(context, false, "Invalid endpoint configuration.", null, cancellationToken);
|
||||
return ChannelDispatchResult.Failed(
|
||||
"Webhook endpoint is not configured or invalid.",
|
||||
ChannelDispatchStatus.InvalidConfiguration);
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
var attempt = 0;
|
||||
var maxRetries = _options.MaxRetries;
|
||||
Exception? lastException = null;
|
||||
int? lastStatusCode = null;
|
||||
|
||||
while (attempt <= maxRetries)
|
||||
{
|
||||
attempt++;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(context, uri);
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
lastStatusCode = (int)response.StatusCode;
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var metadata = BuildSuccessMetadata(context, response, attempt);
|
||||
await AuditDispatchAsync(context, true, null, metadata, cancellationToken);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Webhook delivery {DeliveryId} succeeded to {Endpoint} on attempt {Attempt} in {Duration}ms.",
|
||||
context.DeliveryId, endpoint, attempt, stopwatch.ElapsedMilliseconds);
|
||||
|
||||
return ChannelDispatchResult.Succeeded(
|
||||
message: $"Delivered to {uri.Host} with status {response.StatusCode}.",
|
||||
duration: stopwatch.Elapsed,
|
||||
metadata: metadata);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||
{
|
||||
var retryAfter = ParseRetryAfter(response.Headers);
|
||||
stopwatch.Stop();
|
||||
|
||||
await AuditDispatchAsync(context, false, "Rate limited by endpoint.", null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} throttled by {Endpoint}. Retry after: {RetryAfter}.",
|
||||
context.DeliveryId, endpoint, retryAfter);
|
||||
|
||||
return ChannelDispatchResult.Throttled(
|
||||
$"Rate limited by {uri.Host}.",
|
||||
retryAfter);
|
||||
}
|
||||
|
||||
if (!IsRetryable(response.StatusCode))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var errorMessage = $"Webhook returned non-retryable status {response.StatusCode}.";
|
||||
await AuditDispatchAsync(context, false, errorMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogWarning(
|
||||
"Webhook delivery {DeliveryId} failed with non-retryable status {StatusCode}.",
|
||||
context.DeliveryId, response.StatusCode);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
errorMessage,
|
||||
httpStatusCode: lastStatusCode,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} returned {StatusCode}, will retry.",
|
||||
context.DeliveryId, attempt, response.StatusCode);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} failed with network error.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
lastException = ex;
|
||||
_logger.LogDebug(
|
||||
"Webhook delivery {DeliveryId} attempt {Attempt} timed out.",
|
||||
context.DeliveryId, attempt);
|
||||
}
|
||||
|
||||
if (attempt <= maxRetries)
|
||||
{
|
||||
var delay = CalculateBackoff(attempt);
|
||||
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
var finalMessage = lastException?.Message ?? $"Failed after {maxRetries + 1} attempts.";
|
||||
await AuditDispatchAsync(context, false, finalMessage, null, cancellationToken);
|
||||
|
||||
_logger.LogError(
|
||||
lastException,
|
||||
"Webhook delivery {DeliveryId} exhausted all {MaxRetries} retries to {Endpoint}.",
|
||||
context.DeliveryId, maxRetries + 1, endpoint);
|
||||
|
||||
return ChannelDispatchResult.Failed(
|
||||
finalMessage,
|
||||
lastException is TaskCanceledException ? ChannelDispatchStatus.Timeout : ChannelDispatchStatus.NetworkError,
|
||||
httpStatusCode: lastStatusCode,
|
||||
exception: lastException,
|
||||
duration: stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
public async Task<ChannelHealthCheckResult> CheckHealthAsync(
|
||||
NotifyChannel channel,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channel);
|
||||
|
||||
var endpoint = channel.Config.Endpoint;
|
||||
if (string.IsNullOrWhiteSpace(endpoint) || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return ChannelHealthCheckResult.Unhealthy("Webhook endpoint is not configured or invalid.");
|
||||
}
|
||||
|
||||
if (!channel.Enabled)
|
||||
{
|
||||
return ChannelHealthCheckResult.Degraded("Channel is disabled.");
|
||||
}
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
|
||||
using var response = await _httpClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (response.IsSuccessStatusCode || response.StatusCode == HttpStatusCode.MethodNotAllowed)
|
||||
{
|
||||
return ChannelHealthCheckResult.Ok(
|
||||
$"Endpoint responded with {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
|
||||
return ChannelHealthCheckResult.Degraded(
|
||||
$"Endpoint returned {response.StatusCode}.",
|
||||
stopwatch.Elapsed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
_logger.LogDebug(ex, "Webhook health check failed for channel {ChannelId}.", channel.ChannelId);
|
||||
return ChannelHealthCheckResult.Unhealthy($"Connection failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(ChannelDispatchContext context, Uri uri)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||
request.Content = new StringContent(context.RenderedBody, Encoding.UTF8, "application/json");
|
||||
|
||||
request.Headers.UserAgent.Add(new ProductInfoHeaderValue("StellaOps-Notifier", "1.0"));
|
||||
request.Headers.Add("X-StellaOps-Delivery-Id", context.DeliveryId);
|
||||
request.Headers.Add("X-StellaOps-Trace-Id", context.TraceId);
|
||||
request.Headers.Add("X-StellaOps-Timestamp", context.Timestamp.ToString("O"));
|
||||
|
||||
if (_options.EnableHmacSigning && TryGetHmacSecret(context.Channel, out var secret))
|
||||
{
|
||||
var signature = ComputeHmacSignature(context.RenderedBody, secret);
|
||||
request.Headers.Add("X-StellaOps-Signature", $"sha256={signature}");
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
private static bool TryGetHmacSecret(NotifyChannel channel, out string secret)
|
||||
{
|
||||
secret = string.Empty;
|
||||
if (channel.Config.Properties.TryGetValue("hmacSecret", out var s) && !string.IsNullOrWhiteSpace(s))
|
||||
{
|
||||
secret = s;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string ComputeHmacSignature(string body, string secret)
|
||||
{
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
|
||||
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
private static TimeSpan? ParseRetryAfter(HttpResponseHeaders headers)
|
||||
{
|
||||
if (headers.RetryAfter?.Delta is { } delta)
|
||||
{
|
||||
return delta;
|
||||
}
|
||||
|
||||
if (headers.RetryAfter?.Date is { } date)
|
||||
{
|
||||
var delay = date - DateTimeOffset.UtcNow;
|
||||
return delay > TimeSpan.Zero ? delay : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsRetryable(HttpStatusCode statusCode)
|
||||
{
|
||||
return statusCode switch
|
||||
{
|
||||
HttpStatusCode.RequestTimeout => true,
|
||||
HttpStatusCode.BadGateway => true,
|
||||
HttpStatusCode.ServiceUnavailable => true,
|
||||
HttpStatusCode.GatewayTimeout => true,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private TimeSpan CalculateBackoff(int attempt)
|
||||
{
|
||||
var baseDelay = _options.RetryBaseDelay;
|
||||
var maxDelay = _options.RetryMaxDelay;
|
||||
var jitter = Random.Shared.NextDouble() * 0.3 + 0.85;
|
||||
var delay = TimeSpan.FromMilliseconds(baseDelay.TotalMilliseconds * Math.Pow(2, attempt - 1) * jitter);
|
||||
return delay > maxDelay ? maxDelay : delay;
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildSuccessMetadata(
|
||||
ChannelDispatchContext context,
|
||||
HttpResponseMessage response,
|
||||
int attempt)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["endpoint"] = context.Channel.Config.Endpoint ?? string.Empty,
|
||||
["statusCode"] = ((int)response.StatusCode).ToString(),
|
||||
["attempt"] = attempt.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private async Task AuditDispatchAsync(
|
||||
ChannelDispatchContext context,
|
||||
bool success,
|
||||
string? errorMessage,
|
||||
IReadOnlyDictionary<string, string>? metadata,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var auditMetadata = new Dictionary<string, string>
|
||||
{
|
||||
["deliveryId"] = context.DeliveryId,
|
||||
["channelId"] = context.Channel.ChannelId,
|
||||
["channelType"] = context.Channel.Type.ToString(),
|
||||
["success"] = success.ToString().ToLowerInvariant(),
|
||||
["traceId"] = context.TraceId
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(errorMessage))
|
||||
{
|
||||
auditMetadata["error"] = errorMessage;
|
||||
}
|
||||
|
||||
if (metadata is not null)
|
||||
{
|
||||
foreach (var (key, value) in metadata)
|
||||
{
|
||||
auditMetadata[$"dispatch.{key}"] = value;
|
||||
}
|
||||
}
|
||||
|
||||
await _auditRepository.AppendAsync(
|
||||
context.TenantId,
|
||||
success ? "channel.dispatch.success" : "channel.dispatch.failure",
|
||||
"notifier-worker",
|
||||
auditMetadata,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to write dispatch audit for delivery {DeliveryId}.", context.DeliveryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user