Add Discord notification provider (#417)

This commit is contained in:
Flaminel
2026-01-13 18:53:40 +02:00
committed by GitHub
parent 6abb542271
commit 8bd6b86018
40 changed files with 2861 additions and 25 deletions

View File

@@ -1,5 +1,6 @@
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
@@ -18,6 +19,7 @@ public static class NotificationsDI
.AddScoped<INtfyProxy, NtfyProxy>()
.AddScoped<IPushoverProxy, PushoverProxy>()
.AddScoped<ITelegramProxy, TelegramProxy>()
.AddScoped<IDiscordProxy, DiscordProxy>()
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
.AddScoped<NotificationProviderFactory>()

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record CreateDiscordProviderRequest : CreateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record TestDiscordProviderRequest
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -0,0 +1,10 @@
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
public record UpdateDiscordProviderRequest : UpdateNotificationProviderRequestBase
{
public string WebhookUrl { get; init; } = string.Empty;
public string Username { get; init; } = string.Empty;
public string AvatarUrl { get; init; } = string.Empty;
}

View File

@@ -5,6 +5,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Domain.Exceptions;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
using Cleanuparr.Persistence;
@@ -50,6 +51,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -76,6 +78,7 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => p.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => p.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => p.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => p.DiscordConfiguration ?? new object(),
_ => new object()
}
})
@@ -694,6 +697,7 @@ public sealed class NotificationProvidersController : ControllerBase
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.FirstOrDefaultAsync(p => p.Id == id);
if (existingProvider == null)
@@ -926,11 +930,200 @@ public sealed class NotificationProvidersController : ControllerBase
NotificationProviderType.Ntfy => provider.NtfyConfiguration ?? new object(),
NotificationProviderType.Pushover => provider.PushoverConfiguration ?? new object(),
NotificationProviderType.Telegram => provider.TelegramConfiguration ?? new object(),
NotificationProviderType.Discord => provider.DiscordConfiguration ?? new object(),
_ => new object()
}
};
}
[HttpPost("discord")]
public async Task<IActionResult> CreateDiscordProvider([FromBody] CreateDiscordProviderRequest newProvider)
{
await DataContext.Lock.WaitAsync();
try
{
if (string.IsNullOrWhiteSpace(newProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs.CountAsync(x => x.Name == newProvider.Name);
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = newProvider.WebhookUrl,
Username = newProvider.Username,
AvatarUrl = newProvider.AvatarUrl
};
discordConfig.Validate();
var provider = new NotificationConfig
{
Name = newProvider.Name,
Type = NotificationProviderType.Discord,
IsEnabled = newProvider.IsEnabled,
OnFailedImportStrike = newProvider.OnFailedImportStrike,
OnStalledStrike = newProvider.OnStalledStrike,
OnSlowStrike = newProvider.OnSlowStrike,
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
OnDownloadCleaned = newProvider.OnDownloadCleaned,
OnCategoryChanged = newProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig
};
_dataContext.NotificationConfigs.Add(provider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(provider);
return CreatedAtAction(nameof(GetNotificationProviders), new { id = provider.Id }, providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Discord provider");
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPut("discord/{id:guid}")]
public async Task<IActionResult> UpdateDiscordProvider(Guid id, [FromBody] UpdateDiscordProviderRequest updatedProvider)
{
await DataContext.Lock.WaitAsync();
try
{
var existingProvider = await _dataContext.NotificationConfigs
.Include(p => p.DiscordConfiguration)
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Discord);
if (existingProvider == null)
{
return NotFound($"Discord provider with ID {id} not found");
}
if (string.IsNullOrWhiteSpace(updatedProvider.Name))
{
return BadRequest("Provider name is required");
}
var duplicateConfig = await _dataContext.NotificationConfigs
.Where(x => x.Id != id)
.Where(x => x.Name == updatedProvider.Name)
.CountAsync();
if (duplicateConfig > 0)
{
return BadRequest("A provider with this name already exists");
}
var discordConfig = new DiscordConfig
{
WebhookUrl = updatedProvider.WebhookUrl,
Username = updatedProvider.Username,
AvatarUrl = updatedProvider.AvatarUrl
};
if (existingProvider.DiscordConfiguration != null)
{
discordConfig = discordConfig with { Id = existingProvider.DiscordConfiguration.Id };
}
discordConfig.Validate();
var newProvider = existingProvider with
{
Name = updatedProvider.Name,
IsEnabled = updatedProvider.IsEnabled,
OnFailedImportStrike = updatedProvider.OnFailedImportStrike,
OnStalledStrike = updatedProvider.OnStalledStrike,
OnSlowStrike = updatedProvider.OnSlowStrike,
OnQueueItemDeleted = updatedProvider.OnQueueItemDeleted,
OnDownloadCleaned = updatedProvider.OnDownloadCleaned,
OnCategoryChanged = updatedProvider.OnCategoryChanged,
DiscordConfiguration = discordConfig,
UpdatedAt = DateTime.UtcNow
};
_dataContext.NotificationConfigs.Remove(existingProvider);
_dataContext.NotificationConfigs.Add(newProvider);
await _dataContext.SaveChangesAsync();
await _notificationConfigurationService.InvalidateCacheAsync();
var providerDto = MapProvider(newProvider);
return Ok(providerDto);
}
catch (ValidationException ex)
{
return BadRequest(ex.Message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update Discord provider with ID {Id}", id);
throw;
}
finally
{
DataContext.Lock.Release();
}
}
[HttpPost("discord/test")]
public async Task<IActionResult> TestDiscordProvider([FromBody] TestDiscordProviderRequest testRequest)
{
try
{
var discordConfig = new DiscordConfig
{
WebhookUrl = testRequest.WebhookUrl,
Username = testRequest.Username,
AvatarUrl = testRequest.AvatarUrl
};
discordConfig.Validate();
var providerDto = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "Test Provider",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Events = new NotificationEventFlags
{
OnFailedImportStrike = true,
OnStalledStrike = false,
OnSlowStrike = false,
OnQueueItemDeleted = false,
OnDownloadCleaned = false,
OnCategoryChanged = false
},
Configuration = discordConfig
};
await _notificationService.SendTestNotificationAsync(providerDto);
return Ok(new { Message = "Test notification sent successfully" });
}
catch (DiscordException ex)
{
_logger.LogWarning(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to test Discord provider");
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
}
}
[HttpPost("pushover")]
public async Task<IActionResult> CreatePushoverProvider([FromBody] CreatePushoverProviderRequest newProvider)
{

View File

@@ -7,4 +7,5 @@ public enum NotificationProviderType
Ntfy,
Pushover,
Telegram,
Discord,
}

View File

@@ -0,0 +1,315 @@
using System.Net;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Moq;
using Moq.Protected;
using Xunit;
namespace Cleanuparr.Infrastructure.Tests.Features.Notifications.Discord;
public class DiscordProxyTests
{
private readonly Mock<ILogger<DiscordProxy>> _loggerMock;
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
public DiscordProxyTests()
{
_loggerMock = new Mock<ILogger<DiscordProxy>>();
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
_httpClientFactoryMock
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
.Returns(httpClient);
}
private DiscordProxy CreateProxy()
{
return new DiscordProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
}
private static DiscordPayload CreatePayload()
{
return new DiscordPayload
{
Embeds = new List<DiscordEmbed>
{
new()
{
Title = "Test Title",
Description = "Test Description",
Color = 0x28a745
}
}
};
}
private static DiscordConfig CreateConfig()
{
return new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
}
#region Constructor Tests
[Fact]
public void Constructor_WithValidDependencies_CreatesInstance()
{
// Act
var proxy = CreateProxy();
// Assert
Assert.NotNull(proxy);
}
[Fact]
public void Constructor_CreatesHttpClientWithCorrectName()
{
// Act
_ = CreateProxy();
// Assert
_httpClientFactoryMock.Verify(f => f.CreateClient(Constants.HttpClientWithRetryName), Times.Once);
}
#endregion
#region SendNotification Success Tests
[Fact]
public async Task SendNotification_WhenSuccessful_CompletesWithoutException()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act & Assert - Should not throw
await proxy.SendNotification(CreatePayload(), CreateConfig());
}
[Fact]
public async Task SendNotification_SendsPostRequest()
{
// Arrange
var proxy = CreateProxy();
HttpMethod? capturedMethod = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedMethod = req.Method)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal(HttpMethod.Post, capturedMethod);
}
[Fact]
public async Task SendNotification_BuildsCorrectUrl()
{
// Arrange
var proxy = CreateProxy();
Uri? capturedUri = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) => capturedUri = req.RequestUri)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123/abc"
};
// Act
await proxy.SendNotification(CreatePayload(), config);
// Assert
Assert.NotNull(capturedUri);
Assert.Equal("https://discord.com/api/webhooks/123/abc", capturedUri.ToString());
}
[Fact]
public async Task SendNotification_SetsJsonContentType()
{
// Arrange
var proxy = CreateProxy();
string? capturedContentType = null;
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((req, _) =>
capturedContentType = req.Content?.Headers.ContentType?.MediaType)
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
Assert.Equal("application/json", capturedContentType);
}
[Fact]
public async Task SendNotification_LogsTraceWithContent()
{
// Arrange
var proxy = CreateProxy();
SetupSuccessResponse();
// Act
await proxy.SendNotification(CreatePayload(), CreateConfig());
// Assert
_loggerMock.Verify(
x => x.Log(
LogLevel.Trace,
It.IsAny<EventId>(),
It.Is<It.IsAnyType>((v, t) => v.ToString()!.Contains("sending notification")),
It.IsAny<Exception>(),
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
Times.Once);
}
#endregion
#region SendNotification Error Tests
[Theory]
[InlineData(HttpStatusCode.Unauthorized)]
[InlineData(HttpStatusCode.Forbidden)]
public async Task SendNotification_WhenUnauthorized_ThrowsDiscordExceptionWithInvalidWebhook(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("invalid or unauthorized", ex.Message);
}
[Fact]
public async Task SendNotification_When404_ThrowsDiscordExceptionWithNotFound()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.NotFound);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("not found", ex.Message);
}
[Fact]
public async Task SendNotification_When429_ThrowsDiscordExceptionWithRateLimited()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse((HttpStatusCode)429);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("rate limited", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(HttpStatusCode.BadGateway)]
[InlineData(HttpStatusCode.ServiceUnavailable)]
[InlineData(HttpStatusCode.GatewayTimeout)]
public async Task SendNotification_WhenServiceUnavailable_ThrowsDiscordException(HttpStatusCode statusCode)
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(statusCode);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenOtherError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
SetupErrorResponse(HttpStatusCode.InternalServerError);
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task SendNotification_WhenNetworkError_ThrowsDiscordException()
{
// Arrange
var proxy = CreateProxy();
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Network error"));
// Act & Assert
var ex = await Assert.ThrowsAsync<DiscordException>(() =>
proxy.SendNotification(CreatePayload(), CreateConfig()));
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
}
#endregion
#region Helper Methods
private void SetupSuccessResponse()
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK));
}
private void SetupErrorResponse(HttpStatusCode statusCode)
{
_httpMessageHandlerMock
.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.ThrowsAsync(new HttpRequestException("Error", null, statusCode));
}
#endregion
}

View File

@@ -37,7 +37,7 @@ public class NotifiarrProxyTests
return new NotifiarrPayload
{
Notification = new NotifiarrNotification { Update = false },
Discord = new Discord
Discord = new NotifiarrDiscord
{
Color = "#FF0000",
Text = new Text { Title = "Test", Content = "Test content" },

View File

@@ -364,6 +364,7 @@ public class NotificationConfigurationServiceTests : IDisposable
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[InlineData(NotificationProviderType.Discord)]
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
{
// Arrange
@@ -388,6 +389,7 @@ public class NotificationConfigurationServiceTests : IDisposable
[InlineData(NotificationProviderType.Ntfy)]
[InlineData(NotificationProviderType.Pushover)]
[InlineData(NotificationProviderType.Telegram)]
[InlineData(NotificationProviderType.Discord)]
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
{
// Arrange
@@ -420,6 +422,7 @@ public class NotificationConfigurationServiceTests : IDisposable
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
};
}
@@ -549,5 +552,29 @@ public class NotificationConfigurationServiceTests : IDisposable
};
}
private static NotificationConfig CreateDiscordConfig(string name, bool isEnabled)
{
return new NotificationConfig
{
Id = Guid.NewGuid(),
Name = name,
Type = NotificationProviderType.Discord,
IsEnabled = isEnabled,
OnStalledStrike = true,
OnFailedImportStrike = true,
OnSlowStrike = true,
OnQueueItemDeleted = true,
OnDownloadCleaned = true,
OnCategoryChanged = true,
DiscordConfiguration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "http://localhost:8000",
AvatarUrl = "https://example.com/avatar.png",
Username = "test_username",
}
};
}
#endregion
}

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
@@ -21,6 +22,7 @@ public class NotificationProviderFactoryTests
private readonly Mock<INotifiarrProxy> _notifiarrProxyMock;
private readonly Mock<IPushoverProxy> _pushoverProxyMock;
private readonly Mock<ITelegramProxy> _telegramProxyMock;
private readonly Mock<IDiscordProxy> _discordProxyMock;
private readonly IServiceProvider _serviceProvider;
private readonly NotificationProviderFactory _factory;
@@ -32,6 +34,7 @@ public class NotificationProviderFactoryTests
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
_pushoverProxyMock = new Mock<IPushoverProxy>();
_telegramProxyMock = new Mock<ITelegramProxy>();
_discordProxyMock = new Mock<IDiscordProxy>();
var services = new ServiceCollection();
services.AddSingleton(_appriseProxyMock.Object);
@@ -40,6 +43,7 @@ public class NotificationProviderFactoryTests
services.AddSingleton(_notifiarrProxyMock.Object);
services.AddSingleton(_pushoverProxyMock.Object);
services.AddSingleton(_telegramProxyMock.Object);
services.AddSingleton(_discordProxyMock.Object);
_serviceProvider = services.BuildServiceProvider();
_factory = new NotificationProviderFactory(_serviceProvider);
@@ -194,6 +198,35 @@ public class NotificationProviderFactoryTests
Assert.Equal(NotificationProviderType.Telegram, provider.Type);
}
[Fact]
public void CreateProvider_DiscordType_CreatesDiscordProvider()
{
// Arrange
var config = new NotificationProviderDto
{
Id = Guid.NewGuid(),
Name = "TestDiscord",
Type = NotificationProviderType.Discord,
IsEnabled = true,
Configuration = new DiscordConfig
{
Id = Guid.NewGuid(),
WebhookUrl = "test-webhook-url",
AvatarUrl = "test-avatar-url",
Username = "test-username",
}
};
// Act
var provider = _factory.CreateProvider(config);
// Assert
Assert.NotNull(provider);
Assert.IsType<DiscordProvider>(provider);
Assert.Equal("TestDiscord", provider.Name);
Assert.Equal(NotificationProviderType.Discord, provider.Type);
}
[Fact]
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
{

View File

@@ -0,0 +1,12 @@
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordException : Exception
{
public DiscordException(string message) : base(message)
{
}
public DiscordException(string message, Exception innerException) : base(message, innerException)
{
}
}

View File

@@ -0,0 +1,59 @@
using Newtonsoft.Json;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public class DiscordPayload
{
public string? Username { get; set; }
[JsonProperty("avatar_url")]
public string? AvatarUrl { get; set; }
public List<DiscordEmbed> Embeds { get; set; } = new();
}
public class DiscordEmbed
{
public string Title { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public int Color { get; set; }
public DiscordThumbnail? Thumbnail { get; set; }
public DiscordImage? Image { get; set; }
public List<DiscordField> Fields { get; set; } = new();
public DiscordFooter? Footer { get; set; }
public string? Timestamp { get; set; }
}
public class DiscordField
{
public string Name { get; set; } = string.Empty;
public string Value { get; set; } = string.Empty;
public bool Inline { get; set; }
}
public class DiscordThumbnail
{
public string Url { get; set; } = string.Empty;
}
public class DiscordImage
{
public string Url { get; set; } = string.Empty;
}
public class DiscordFooter
{
public string Text { get; set; } = string.Empty;
[JsonProperty("icon_url")]
public string? IconUrl { get; set; }
}

View File

@@ -0,0 +1,93 @@
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProvider : NotificationProviderBase<DiscordConfig>
{
private readonly IDiscordProxy _proxy;
public DiscordProvider(
string name,
NotificationProviderType type,
DiscordConfig config,
IDiscordProxy proxy)
: base(name, type, config)
{
_proxy = proxy;
}
public override async Task SendNotificationAsync(NotificationContext context)
{
var payload = BuildPayload(context);
await _proxy.SendNotification(payload, Config);
}
private DiscordPayload BuildPayload(NotificationContext context)
{
var color = context.Severity switch
{
EventSeverity.Warning => 0xf0ad4e, // Orange/yellow
EventSeverity.Important => 0xbb2124, // Red
_ => 0x28a745 // Green
};
var embed = new DiscordEmbed
{
Title = context.Title,
Description = context.Description,
Color = color,
Thumbnail = new DiscordThumbnail { Url = Constants.LogoUrl },
Fields = BuildFields(context),
Footer = new DiscordFooter
{
Text = "Cleanuparr",
IconUrl = Constants.LogoUrl
},
Timestamp = DateTime.UtcNow.ToString("o")
};
if (context.Image != null)
{
embed.Image = new DiscordImage { Url = context.Image.ToString() };
}
var payload = new DiscordPayload
{
Embeds = new List<DiscordEmbed> { embed }
};
// Apply username override if configured
if (!string.IsNullOrWhiteSpace(Config.Username))
{
payload.Username = Config.Username;
}
// Apply avatar override if configured
if (!string.IsNullOrWhiteSpace(Config.AvatarUrl))
{
payload.AvatarUrl = Config.AvatarUrl;
}
return payload;
}
private List<DiscordField> BuildFields(NotificationContext context)
{
var fields = new List<DiscordField>();
foreach ((string key, string value) in context.Data)
{
fields.Add(new DiscordField
{
Name = key,
Value = value,
Inline = false
});
}
return fields;
}
}

View File

@@ -0,0 +1,64 @@
using System.Text;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public sealed class DiscordProxy : IDiscordProxy
{
private readonly ILogger<DiscordProxy> _logger;
private readonly HttpClient _httpClient;
public DiscordProxy(ILogger<DiscordProxy> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
}
public async Task SendNotification(DiscordPayload payload, DiscordConfig config)
{
try
{
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Ignore
});
_logger.LogTrace("sending notification to Discord: {content}", content);
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, config.WebhookUrl);
request.Content = new StringContent(content, Encoding.UTF8, "application/json");
using HttpResponseMessage response = await _httpClient.SendAsync(request);
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException exception)
{
if (exception.StatusCode is null)
{
throw new DiscordException("unable to send notification", exception);
}
switch ((int)exception.StatusCode)
{
case 401:
case 403:
throw new DiscordException("unable to send notification | webhook URL is invalid or unauthorized");
case 404:
throw new DiscordException("unable to send notification | webhook not found");
case 429:
throw new DiscordException("unable to send notification | rate limited, please try again later", exception);
case 502:
case 503:
case 504:
throw new DiscordException("unable to send notification | Discord service unavailable", exception);
default:
throw new DiscordException("unable to send notification", exception);
}
}
}
}

View File

@@ -0,0 +1,8 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
namespace Cleanuparr.Infrastructure.Features.Notifications.Discord;
public interface IDiscordProxy
{
Task SendNotification(DiscordPayload payload, DiscordConfig config);
}

View File

@@ -3,7 +3,7 @@
public class NotifiarrPayload
{
public NotifiarrNotification Notification { get; set; } = new NotifiarrNotification();
public Discord Discord { get; set; }
public NotifiarrDiscord Discord { get; set; }
}
public class NotifiarrNotification
@@ -13,7 +13,7 @@ public class NotifiarrNotification
public int? Event { get; set; }
}
public class Discord
public class NotifiarrDiscord
{
public string Color { get; set; } = string.Empty;
public Ping Ping { get; set; }

View File

@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Cleanuparr.Shared.Helpers;
namespace Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
@@ -34,8 +35,6 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
_ => "28a745"
};
const string logo = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
return new NotifiarrPayload
{
Discord = new()
@@ -44,7 +43,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
Text = new()
{
Title = context.Title,
Icon = logo,
Icon = Constants.LogoUrl,
Description = context.Description,
Fields = BuildFields(context)
},
@@ -54,7 +53,7 @@ public sealed class NotifiarrProvider : NotificationProviderBase<NotifiarrConfig
},
Images = new()
{
Thumbnail = new Uri(logo),
Thumbnail = new Uri(Constants.LogoUrl),
Image = context.Image
}
}

View File

@@ -88,6 +88,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
.Include(p => p.NtfyConfiguration)
.Include(p => p.PushoverConfiguration)
.Include(p => p.TelegramConfiguration)
.Include(p => p.DiscordConfiguration)
.AsNoTracking()
.ToListAsync();
@@ -139,6 +140,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
NotificationProviderType.Ntfy => config.NtfyConfiguration,
NotificationProviderType.Pushover => config.PushoverConfiguration,
NotificationProviderType.Telegram => config.TelegramConfiguration,
NotificationProviderType.Discord => config.DiscordConfiguration,
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
};

View File

@@ -1,6 +1,7 @@
using Cleanuparr.Domain.Entities;
using Cleanuparr.Domain.Enums;
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
using Cleanuparr.Infrastructure.Features.Notifications.Models;
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
@@ -29,6 +30,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
NotificationProviderType.Pushover => CreatePushoverProvider(config),
NotificationProviderType.Telegram => CreateTelegramProvider(config),
NotificationProviderType.Discord => CreateDiscordProvider(config),
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
};
}
@@ -73,4 +75,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
return new TelegramProvider(config.Name, config.Type, telegramConfig, proxy);
}
private INotificationProvider CreateDiscordProvider(NotificationProviderDto config)
{
var discordConfig = (DiscordConfig)config.Configuration;
var proxy = _serviceProvider.GetRequiredService<IDiscordProxy>();
return new DiscordProvider(config.Name, config.Type, discordConfig, proxy);
}
}

View File

@@ -0,0 +1,149 @@
using Cleanuparr.Persistence.Models.Configuration.Notification;
using Shouldly;
using Xunit;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Tests.Models.Configuration.Notification;
public sealed class DiscordConfigTests
{
#region IsValid Tests
[Fact]
public void IsValid_WithValidWebhookUrl_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij"
};
config.IsValid().ShouldBeTrue();
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void IsValid_WithEmptyOrNullWebhookUrl_ReturnsFalse(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
config.IsValid().ShouldBeFalse();
}
[Fact]
public void IsValid_WithOptionalFieldsEmpty_ReturnsTrue()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "",
AvatarUrl = ""
};
config.IsValid().ShouldBeTrue();
}
#endregion
#region Validate Tests
[Fact]
public void Validate_WithValidConfig_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
Username = "Test Bot",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Validate_WithEmptyOrNullWebhookUrl_ThrowsValidationException(string? webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl ?? string.Empty
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("required");
}
[Theory]
[InlineData("https://example.com/webhook")]
[InlineData("http://discord.com/api/webhooks/123/abc")]
[InlineData("not-a-url")]
[InlineData("https://discord.com/api/something-else")]
public void Validate_WithInvalidWebhookUrl_ThrowsValidationException(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid Discord webhook URL");
}
[Theory]
[InlineData("https://discord.com/api/webhooks/123456789/abcdefghij")]
[InlineData("https://discordapp.com/api/webhooks/123456789/abcdefghij")]
public void Validate_WithValidWebhookUrls_DoesNotThrow(string webhookUrl)
{
var config = new DiscordConfig
{
WebhookUrl = webhookUrl
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithInvalidAvatarUrl_ThrowsValidationException()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "not-a-valid-url"
};
var ex = Should.Throw<ValidationException>(() => config.Validate());
ex.Message.ShouldContain("valid URL");
}
[Fact]
public void Validate_WithValidAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = "https://example.com/avatar.png"
};
Should.NotThrow(() => config.Validate());
}
[Fact]
public void Validate_WithEmptyAvatarUrl_DoesNotThrow()
{
var config = new DiscordConfig
{
WebhookUrl = "https://discord.com/api/webhooks/123456789/abcdefghij",
AvatarUrl = ""
};
Should.NotThrow(() => config.Validate());
}
#endregion
}

View File

@@ -56,6 +56,8 @@ public class DataContext : DbContext
public DbSet<TelegramConfig> TelegramConfigs { get; set; }
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
@@ -156,6 +158,11 @@ public class DataContext : DbContext
.HasForeignKey<TelegramConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.DiscordConfiguration)
.WithOne(c => c.NotificationConfig)
.HasForeignKey<DiscordConfig>(c => c.NotificationConfigId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasIndex(p => p.Name).IsUnique();
});

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,49 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Cleanuparr.Persistence.Migrations.Data
{
/// <inheritdoc />
public partial class AddDiscord : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "discord_configs",
columns: table => new
{
id = table.Column<Guid>(type: "TEXT", nullable: false),
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
webhook_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
username = table.Column<string>(type: "TEXT", maxLength: 80, nullable: false),
avatar_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("pk_discord_configs", x => x.id);
table.ForeignKey(
name: "fk_discord_configs_notification_configs_notification_config_id",
column: x => x.notification_config_id,
principalTable: "notification_configs",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "ix_discord_configs_notification_config_id",
table: "discord_configs",
column: "notification_config_id",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "discord_configs");
}
}
}

View File

@@ -16,7 +16,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.6");
modelBuilder.HasAnnotation("ProductVersion", "10.0.1");
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Arr.ArrConfig", b =>
{
@@ -302,7 +302,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("search_enabled");
b.ComplexProperty<Dictionary<string, object>>("Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Log", "Cleanuparr.Persistence.Models.Configuration.General.GeneralConfig.Log#LoggingConfig", b1 =>
{
b1.IsRequired();
@@ -379,7 +379,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Lidarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Lidarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -397,7 +397,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("lidarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Radarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Radarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -415,7 +415,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("radarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Readarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Readarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -433,7 +433,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("readarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Sonarr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Sonarr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -451,7 +451,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnName("sonarr_enabled");
});
b.ComplexProperty<Dictionary<string, object>>("Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "Whisparr", "Cleanuparr.Persistence.Models.Configuration.MalwareBlocker.ContentBlockerConfig.Whisparr#BlocklistSettings", b1 =>
{
b1.IsRequired();
@@ -522,6 +522,45 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.ToTable("apprise_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AvatarUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("avatar_url");
b.Property<Guid>("NotificationConfigId")
.HasColumnType("TEXT")
.HasColumnName("notification_config_id");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(80)
.HasColumnType("TEXT")
.HasColumnName("username");
b.Property<string>("WebhookUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT")
.HasColumnName("webhook_url");
b.HasKey("Id")
.HasName("pk_discord_configs");
b.HasIndex("NotificationConfigId")
.IsUnique()
.HasDatabaseName("ix_discord_configs_notification_config_id");
b.ToTable("discord_configs", (string)null);
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.Property<Guid>("Id")
@@ -813,7 +852,7 @@ namespace Cleanuparr.Persistence.Migrations.Data
.HasColumnType("INTEGER")
.HasColumnName("use_advanced_scheduling");
b.ComplexProperty<Dictionary<string, object>>("FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
b.ComplexProperty(typeof(Dictionary<string, object>), "FailedImport", "Cleanuparr.Persistence.Models.Configuration.QueueCleaner.QueueCleanerConfig.FailedImport#FailedImportConfig", b1 =>
{
b1.IsRequired();
@@ -1043,6 +1082,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
.WithOne("DiscordConfiguration")
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.DiscordConfig", "NotificationConfigId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_discord_configs_notification_configs_notification_config_id");
b.Navigation("NotificationConfig");
});
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
{
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
@@ -1141,6 +1192,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
{
b.Navigation("AppriseConfiguration");
b.Navigation("DiscordConfiguration");
b.Navigation("NotifiarrConfiguration");
b.Navigation("NtfyConfiguration");

View File

@@ -0,0 +1,54 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Cleanuparr.Persistence.Models.Configuration;
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
public sealed record DiscordConfig : IConfig
{
[Key]
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
public Guid Id { get; init; } = Guid.NewGuid();
[Required]
public Guid NotificationConfigId { get; init; }
[ForeignKey(nameof(NotificationConfigId))]
public NotificationConfig NotificationConfig { get; init; } = null!;
[Required]
[MaxLength(500)]
public string WebhookUrl { get; init; } = string.Empty;
[MaxLength(80)]
public string Username { get; init; } = string.Empty;
[MaxLength(500)]
public string AvatarUrl { get; init; } = string.Empty;
public bool IsValid()
{
return !string.IsNullOrWhiteSpace(WebhookUrl);
}
public void Validate()
{
if (string.IsNullOrWhiteSpace(WebhookUrl))
{
throw new ValidationException("Discord webhook URL is required");
}
if (!WebhookUrl.StartsWith("https://discord.com/api/webhooks/", StringComparison.OrdinalIgnoreCase) &&
!WebhookUrl.StartsWith("https://discordapp.com/api/webhooks/", StringComparison.OrdinalIgnoreCase))
{
throw new ValidationException("Discord webhook URL must be a valid Discord webhook URL");
}
if (!string.IsNullOrWhiteSpace(AvatarUrl) &&
!Uri.TryCreate(AvatarUrl, UriKind.Absolute, out var uri))
{
throw new ValidationException("Avatar URL must be a valid URL");
}
}
}

View File

@@ -45,6 +45,8 @@ public sealed record NotificationConfig
public TelegramConfig? TelegramConfiguration { get; init; }
public DiscordConfig? DiscordConfiguration { get; init; }
[NotMapped]
public bool IsConfigured => Type switch
{
@@ -53,6 +55,7 @@ public sealed record NotificationConfig
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true,
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
};

View File

@@ -17,4 +17,6 @@ public static class Constants
public const int DefaultSearchDelaySeconds = 120;
public const int MinSearchDelaySeconds = 60;
public const string LogoUrl = "https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true";
}