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

This commit is contained in:
StellaOps Bot
2025-12-13 00:20:26 +02:00
parent e1f1bef4c1
commit 564df71bfb
2376 changed files with 334389 additions and 328032 deletions

View File

@@ -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;
}
}

View File

@@ -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);
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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
};
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}