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
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user