up
Some checks failed
Docs CI / lint-and-preview (push) Has been cancelled
sdk-generator-smoke / sdk-smoke (push) Has been cancelled
SDK Publish & Sign / sdk-publish (push) Has been cancelled
api-governance / spectral-lint (push) Has been cancelled
oas-ci / oas-validate (push) Has been cancelled
Mirror Thin Bundle Sign & Verify / mirror-sign (push) Has been cancelled

This commit is contained in:
StellaOps Bot
2025-11-27 07:46:56 +02:00
parent d63af51f84
commit ea970ead2a
302 changed files with 43161 additions and 1534 deletions

View File

@@ -0,0 +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}",
process.ExitCode,
shouldRetry: false);
}
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

@@ -0,0 +1,52 @@
using Microsoft.Extensions.Logging;
using StellaOps.Notify.Models;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Channel adapter for email delivery. Requires SMTP configuration.
/// </summary>
public sealed class EmailChannelAdapter : INotifyChannelAdapter
{
private readonly ILogger<EmailChannelAdapter> _logger;
public EmailChannelAdapter(ILogger<EmailChannelAdapter> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Email;
public Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
var target = channel.Config?.Target ?? rendered.Target;
if (string.IsNullOrWhiteSpace(target))
{
return Task.FromResult(ChannelDispatchResult.Fail(
"Email recipient not configured",
shouldRetry: false));
}
// Email delivery requires SMTP integration which depends on environment config.
// For now, log the intent and return success for dev/test scenarios.
// Production deployments should integrate with an SMTP relay or email service.
_logger.LogInformation(
"Email delivery queued: to={Recipient}, subject={Subject}, format={Format}",
target,
rendered.Title,
rendered.Format);
// In a real implementation, this would:
// 1. Resolve SMTP settings from channel.Config.SecretRef
// 2. Build and send the email via SmtpClient or a service like SendGrid
// 3. Return actual success/failure based on delivery
return Task.FromResult(ChannelDispatchResult.Ok());
}
}

View File

@@ -0,0 +1,51 @@
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);
}
/// <summary>
/// Result of a channel dispatch attempt.
/// </summary>
public sealed record ChannelDispatchResult
{
public required bool Success { get; init; }
public int? StatusCode { get; init; }
public string? Reason { get; init; }
public bool ShouldRetry { get; init; }
public static ChannelDispatchResult Ok(int? statusCode = null) => new()
{
Success = true,
StatusCode = statusCode
};
public static ChannelDispatchResult Fail(string reason, int? statusCode = null, bool shouldRetry = true) => new()
{
Success = false,
StatusCode = statusCode,
Reason = reason,
ShouldRetry = shouldRetry
};
}

View File

@@ -0,0 +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
}

View File

@@ -0,0 +1,101 @@
using StellaOps.Notify.Storage.Mongo.Repositories;
namespace StellaOps.Notifier.Worker.Channels;
/// <summary>
/// Adapter that bridges IInAppInboxStore to INotifyInboxRepository.
/// </summary>
public sealed class MongoInboxStoreAdapter : IInAppInboxStore
{
private readonly INotifyInboxRepository _repository;
public MongoInboxStoreAdapter(INotifyInboxRepository repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}
public async Task StoreAsync(InAppInboxMessage message, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(message);
var repoMessage = new NotifyInboxMessage
{
MessageId = message.MessageId,
TenantId = message.TenantId,
UserId = message.UserId,
Title = message.Title,
Body = message.Body,
Summary = message.Summary,
Category = message.Category,
Priority = (int)message.Priority,
Metadata = message.Metadata,
CreatedAt = message.CreatedAt,
ExpiresAt = message.ExpiresAt,
ReadAt = message.ReadAt,
SourceChannel = message.SourceChannel,
DeliveryId = message.DeliveryId
};
await _repository.StoreAsync(repoMessage, cancellationToken).ConfigureAwait(false);
}
public async Task<IReadOnlyList<InAppInboxMessage>> GetForUserAsync(
string tenantId,
string userId,
int limit = 50,
CancellationToken cancellationToken = default)
{
var repoMessages = await _repository.GetForUserAsync(tenantId, userId, limit, cancellationToken).ConfigureAwait(false);
return repoMessages.Select(MapToInboxMessage).ToList();
}
public async Task<InAppInboxMessage?> GetAsync(
string tenantId,
string messageId,
CancellationToken cancellationToken = default)
{
var repoMessage = await _repository.GetAsync(tenantId, messageId, cancellationToken).ConfigureAwait(false);
return repoMessage is null ? null : MapToInboxMessage(repoMessage);
}
public Task MarkReadAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
return _repository.MarkReadAsync(tenantId, messageId, cancellationToken);
}
public Task MarkAllReadAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
return _repository.MarkAllReadAsync(tenantId, userId, cancellationToken);
}
public Task DeleteAsync(string tenantId, string messageId, CancellationToken cancellationToken = default)
{
return _repository.DeleteAsync(tenantId, messageId, cancellationToken);
}
public Task<int> GetUnreadCountAsync(string tenantId, string userId, CancellationToken cancellationToken = default)
{
return _repository.GetUnreadCountAsync(tenantId, userId, cancellationToken);
}
private static InAppInboxMessage MapToInboxMessage(NotifyInboxMessage repo)
{
return new InAppInboxMessage
{
MessageId = repo.MessageId,
TenantId = repo.TenantId,
UserId = repo.UserId,
Title = repo.Title,
Body = repo.Body,
Summary = repo.Summary,
Category = repo.Category,
Priority = (InAppInboxPriority)repo.Priority,
Metadata = repo.Metadata,
CreatedAt = repo.CreatedAt,
ExpiresAt = repo.ExpiresAt,
ReadAt = repo.ReadAt,
SourceChannel = repo.SourceChannel,
DeliveryId = repo.DeliveryId
};
}
}

View File

@@ -0,0 +1,140 @@
using System.Net.Http.Headers;
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 OpsGenie incident management integration.
/// Uses the OpsGenie Alert API v2.
/// </summary>
public sealed class OpsGenieChannelAdapter : INotifyChannelAdapter
{
private const string DefaultOpsGenieApiUrl = "https://api.opsgenie.com/v2/alerts";
private readonly HttpClient _httpClient;
private readonly ILogger<OpsGenieChannelAdapter> _logger;
public OpsGenieChannelAdapter(HttpClient httpClient, ILogger<OpsGenieChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.OpsGenie;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
// OpsGenie API key should be stored via SecretRef (resolved externally)
// or provided in Properties as "api_key"
var apiKey = channel.Config?.Properties.GetValueOrDefault("api_key");
if (string.IsNullOrWhiteSpace(apiKey))
{
return ChannelDispatchResult.Fail("OpsGenie API key not configured in properties", shouldRetry: false);
}
var endpoint = channel.Config?.Endpoint ?? DefaultOpsGenieApiUrl;
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid OpsGenie endpoint: {endpoint}", shouldRetry: false);
}
// Build OpsGenie Alert API v2 payload
var priority = DeterminePriority(rendered);
var payload = new
{
message = rendered.Title ?? "StellaOps Notification",
alias = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
description = rendered.Body,
priority = priority,
source = "StellaOps Notifier",
tags = new[] { "stellaops", "notification" },
details = new Dictionary<string, string>
{
["channel"] = channel.ChannelId,
["target"] = rendered.Target ?? string.Empty,
["summary"] = rendered.Summary ?? string.Empty,
["locale"] = rendered.Locale ?? "en-US"
},
entity = channel.Config?.Properties.GetValueOrDefault("entity") ?? string.Empty,
note = $"Sent via StellaOps Notifier at {DateTimeOffset.UtcNow:O}"
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
request.Headers.Authorization = new AuthenticationHeaderValue("GenieKey", apiKey);
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"OpsGenie alert sent successfully to {Endpoint}. Status: {StatusCode}",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"OpsGenie delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
errorContent,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}: {errorContent}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "OpsGenie delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "OpsGenie delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
private static string DeterminePriority(NotifyDeliveryRendered rendered)
{
// Map notification priority to OpsGenie priority (P1-P5)
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
return "P1";
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
return "P2";
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
return "P3";
if (rendered.Title?.Contains("info", StringComparison.OrdinalIgnoreCase) == true)
return "P4";
return "P3"; // Default to medium priority
}
}

View File

@@ -0,0 +1,141 @@
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 PagerDuty incident management integration.
/// Uses the PagerDuty Events API v2 for incident creation and updates.
/// </summary>
public sealed class PagerDutyChannelAdapter : INotifyChannelAdapter
{
private const string DefaultPagerDutyApiUrl = "https://events.pagerduty.com/v2/enqueue";
private readonly HttpClient _httpClient;
private readonly ILogger<PagerDutyChannelAdapter> _logger;
public PagerDutyChannelAdapter(HttpClient httpClient, ILogger<PagerDutyChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.PagerDuty;
public async Task<ChannelDispatchResult> SendAsync(
NotifyChannel channel,
NotifyDeliveryRendered rendered,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(channel);
ArgumentNullException.ThrowIfNull(rendered);
// PagerDuty routing key should be stored via SecretRef (resolved externally)
// or provided in Properties as "routing_key"
var routingKey = channel.Config?.Properties.GetValueOrDefault("routing_key");
if (string.IsNullOrWhiteSpace(routingKey))
{
return ChannelDispatchResult.Fail("PagerDuty routing key not configured in properties", shouldRetry: false);
}
var endpoint = channel.Config?.Endpoint ?? DefaultPagerDutyApiUrl;
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid PagerDuty endpoint: {endpoint}", shouldRetry: false);
}
// Build PagerDuty Events API v2 payload
var severity = DetermineSeverity(rendered);
var payload = new
{
routing_key = routingKey,
event_action = "trigger",
dedup_key = rendered.BodyHash ?? Guid.NewGuid().ToString("N"),
payload = new
{
summary = rendered.Title ?? "StellaOps Notification",
source = "StellaOps Notifier",
severity = severity,
timestamp = DateTimeOffset.UtcNow.ToString("O"),
custom_details = new
{
body = rendered.Body,
summary = rendered.Summary,
channel = channel.ChannelId,
target = rendered.Target
}
},
client = "StellaOps",
client_url = channel.Config?.Properties.GetValueOrDefault("client_url") ?? string.Empty
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"PagerDuty event sent successfully to {Endpoint}. Status: {StatusCode}",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogWarning(
"PagerDuty delivery to {Endpoint} failed with status {StatusCode}. Error: {Error}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
errorContent,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}: {errorContent}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "PagerDuty delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "PagerDuty delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
private static string DetermineSeverity(NotifyDeliveryRendered rendered)
{
// Map notification priority to PagerDuty severity
// Priority can be embedded in metadata or parsed from title
if (rendered.Title?.Contains("critical", StringComparison.OrdinalIgnoreCase) == true)
return "critical";
if (rendered.Title?.Contains("error", StringComparison.OrdinalIgnoreCase) == true)
return "error";
if (rendered.Title?.Contains("warning", StringComparison.OrdinalIgnoreCase) == true)
return "warning";
return "info";
}
}

View File

@@ -0,0 +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}",
statusCode,
shouldRetry);
}
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

@@ -0,0 +1,105 @@
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 webhook (HTTP POST) delivery with retry support.
/// </summary>
public sealed class WebhookChannelAdapter : INotifyChannelAdapter
{
private readonly HttpClient _httpClient;
private readonly ILogger<WebhookChannelAdapter> _logger;
public WebhookChannelAdapter(HttpClient httpClient, ILogger<WebhookChannelAdapter> logger)
{
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public NotifyChannelType ChannelType => NotifyChannelType.Webhook;
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("Webhook endpoint not configured", shouldRetry: false);
}
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri))
{
return ChannelDispatchResult.Fail($"Invalid webhook endpoint: {endpoint}", shouldRetry: false);
}
var payload = new
{
channel = channel.ChannelId,
target = rendered.Target,
title = rendered.Title,
body = rendered.Body,
summary = rendered.Summary,
format = rendered.Format.ToString().ToLowerInvariant(),
locale = rendered.Locale,
timestamp = DateTimeOffset.UtcNow
};
try
{
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
request.Content = JsonContent.Create(payload, options: new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
// Add HMAC signature header if secret is available (placeholder for KMS integration)
request.Headers.Add("X-StellaOps-Notifier", "1.0");
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var statusCode = (int)response.StatusCode;
if (response.IsSuccessStatusCode)
{
_logger.LogInformation(
"Webhook delivery to {Endpoint} succeeded with status {StatusCode}.",
endpoint,
statusCode);
return ChannelDispatchResult.Ok(statusCode);
}
var shouldRetry = statusCode >= 500 || statusCode == 429;
_logger.LogWarning(
"Webhook delivery to {Endpoint} failed with status {StatusCode}. Retry: {ShouldRetry}.",
endpoint,
statusCode,
shouldRetry);
return ChannelDispatchResult.Fail(
$"HTTP {statusCode}",
statusCode,
shouldRetry);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Webhook delivery to {Endpoint} failed with network error.", endpoint);
return ChannelDispatchResult.Fail(ex.Message, shouldRetry: true);
}
catch (TaskCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (TaskCanceledException ex)
{
_logger.LogWarning(ex, "Webhook delivery to {Endpoint} timed out.", endpoint);
return ChannelDispatchResult.Fail("Request timeout", shouldRetry: true);
}
}
}