mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-14 08:47:57 -05:00
Compare commits
7 Commits
v2.5.0
...
add_gotify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a79e7778c | ||
|
|
27141e7b8c | ||
|
|
31d36c71bb | ||
|
|
8bd6b86018 | ||
|
|
6abb542271 | ||
|
|
2aceae3078 | ||
|
|
65b200a68e |
4
.github/workflows/build-macos-installer.yml
vendored
4
.github/workflows/build-macos-installer.yml
vendored
@@ -21,12 +21,12 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- arch: Intel
|
||||
runner: macos-13
|
||||
runner: macos-15-intel
|
||||
runtime: osx-x64
|
||||
min_os_version: "10.15"
|
||||
artifact_suffix: intel
|
||||
- arch: ARM
|
||||
runner: macos-14
|
||||
runner: macos-15
|
||||
runtime: osx-arm64
|
||||
min_os_version: "11.0"
|
||||
artifact_suffix: arm64
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
namespace Cleanuparr.Api.DependencyInjection;
|
||||
|
||||
@@ -18,6 +20,8 @@ public static class NotificationsDI
|
||||
.AddScoped<INtfyProxy, NtfyProxy>()
|
||||
.AddScoped<IPushoverProxy, PushoverProxy>()
|
||||
.AddScoped<ITelegramProxy, TelegramProxy>()
|
||||
.AddScoped<IDiscordProxy, DiscordProxy>()
|
||||
.AddScoped<IGotifyProxy, GotifyProxy>()
|
||||
.AddScoped<INotificationConfigurationService, NotificationConfigurationService>()
|
||||
.AddScoped<INotificationProviderFactory, NotificationProviderFactory>()
|
||||
.AddScoped<NotificationProviderFactory>()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record CreateGotifyProviderRequest : CreateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record TestGotifyProviderRequest
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace Cleanuparr.Api.Features.Notifications.Contracts.Requests;
|
||||
|
||||
public record UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -5,8 +5,10 @@ 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.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -50,6 +52,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -76,6 +80,8 @@ 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(),
|
||||
NotificationProviderType.Gotify => p.GotifyConfiguration ?? new object(),
|
||||
_ => new object()
|
||||
}
|
||||
})
|
||||
@@ -694,6 +700,8 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -926,11 +934,201 @@ 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(),
|
||||
NotificationProviderType.Gotify => provider.GotifyConfiguration ?? 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)
|
||||
{
|
||||
@@ -1128,4 +1326,192 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("gotify")]
|
||||
public async Task<IActionResult> CreateGotifyProvider([FromBody] CreateGotifyProviderRequest 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 gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = newProvider.ServerUrl,
|
||||
ApplicationToken = newProvider.ApplicationToken,
|
||||
Priority = newProvider.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
|
||||
var provider = new NotificationConfig
|
||||
{
|
||||
Name = newProvider.Name,
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = newProvider.IsEnabled,
|
||||
OnFailedImportStrike = newProvider.OnFailedImportStrike,
|
||||
OnStalledStrike = newProvider.OnStalledStrike,
|
||||
OnSlowStrike = newProvider.OnSlowStrike,
|
||||
OnQueueItemDeleted = newProvider.OnQueueItemDeleted,
|
||||
OnDownloadCleaned = newProvider.OnDownloadCleaned,
|
||||
OnCategoryChanged = newProvider.OnCategoryChanged,
|
||||
GotifyConfiguration = gotifyConfig
|
||||
};
|
||||
|
||||
_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 Gotify provider");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("gotify/{id:guid}")]
|
||||
public async Task<IActionResult> UpdateGotifyProvider(Guid id, [FromBody] UpdateGotifyProviderRequest updatedProvider)
|
||||
{
|
||||
await DataContext.Lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var existingProvider = await _dataContext.NotificationConfigs
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id && p.Type == NotificationProviderType.Gotify);
|
||||
|
||||
if (existingProvider == null)
|
||||
{
|
||||
return NotFound($"Gotify 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 gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = updatedProvider.ServerUrl,
|
||||
ApplicationToken = updatedProvider.ApplicationToken,
|
||||
Priority = updatedProvider.Priority
|
||||
};
|
||||
|
||||
if (existingProvider.GotifyConfiguration != null)
|
||||
{
|
||||
gotifyConfig = gotifyConfig with { Id = existingProvider.GotifyConfiguration.Id };
|
||||
}
|
||||
gotifyConfig.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,
|
||||
GotifyConfiguration = gotifyConfig,
|
||||
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 Gotify provider with ID {Id}", id);
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DataContext.Lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("gotify/test")]
|
||||
public async Task<IActionResult> TestGotifyProvider([FromBody] TestGotifyProviderRequest testRequest)
|
||||
{
|
||||
try
|
||||
{
|
||||
var gotifyConfig = new GotifyConfig
|
||||
{
|
||||
ServerUrl = testRequest.ServerUrl,
|
||||
ApplicationToken = testRequest.ApplicationToken,
|
||||
Priority = testRequest.Priority
|
||||
};
|
||||
gotifyConfig.Validate();
|
||||
|
||||
var providerDto = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "Test Provider",
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = true,
|
||||
Events = new NotificationEventFlags
|
||||
{
|
||||
OnFailedImportStrike = true,
|
||||
OnStalledStrike = false,
|
||||
OnSlowStrike = false,
|
||||
OnQueueItemDeleted = false,
|
||||
OnDownloadCleaned = false,
|
||||
OnCategoryChanged = false
|
||||
},
|
||||
Configuration = gotifyConfig
|
||||
};
|
||||
|
||||
await _notificationService.SendTestNotificationAsync(providerDto);
|
||||
return Ok(new { Message = "Test notification sent successfully" });
|
||||
}
|
||||
catch (GotifyException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to test Gotify provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test Gotify provider");
|
||||
return BadRequest(new { Message = $"Test failed: {ex.Message}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,6 @@ public enum NotificationProviderType
|
||||
Ntfy,
|
||||
Pushover,
|
||||
Telegram,
|
||||
Discord,
|
||||
Gotify,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
using System.Net;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
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.Gotify;
|
||||
|
||||
public class GotifyProxyTests
|
||||
{
|
||||
private readonly Mock<ILogger<GotifyProxy>> _loggerMock;
|
||||
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock;
|
||||
private readonly Mock<HttpMessageHandler> _httpMessageHandlerMock;
|
||||
|
||||
public GotifyProxyTests()
|
||||
{
|
||||
_loggerMock = new Mock<ILogger<GotifyProxy>>();
|
||||
_httpMessageHandlerMock = new Mock<HttpMessageHandler>();
|
||||
_httpClientFactoryMock = new Mock<IHttpClientFactory>();
|
||||
|
||||
var httpClient = new HttpClient(_httpMessageHandlerMock.Object);
|
||||
_httpClientFactoryMock
|
||||
.Setup(f => f.CreateClient(Constants.HttpClientWithRetryName))
|
||||
.Returns(httpClient);
|
||||
}
|
||||
|
||||
private GotifyProxy CreateProxy()
|
||||
{
|
||||
return new GotifyProxy(_loggerMock.Object, _httpClientFactoryMock.Object);
|
||||
}
|
||||
|
||||
private static GotifyPayload CreatePayload()
|
||||
{
|
||||
return new GotifyPayload
|
||||
{
|
||||
Title = "Test Title",
|
||||
Message = "Test Message",
|
||||
Priority = 5
|
||||
};
|
||||
}
|
||||
|
||||
private static GotifyConfig CreateConfig()
|
||||
{
|
||||
return new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-app-token",
|
||||
Priority = 5
|
||||
};
|
||||
}
|
||||
|
||||
#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 GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "my-token",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://gotify.example.com/message?token=my-token", capturedUri.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_TrimsTrailingSlashFromServerUrl()
|
||||
{
|
||||
// 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 GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com/",
|
||||
ApplicationToken = "my-token",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
// Act
|
||||
await proxy.SendNotification(CreatePayload(), config);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedUri);
|
||||
Assert.Equal("https://gotify.example.com/message?token=my-token", 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_ThrowsGotifyExceptionWithInvalidToken(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("invalid or unauthorized", ex.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_When404_ThrowsGotifyExceptionWithNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.NotFound);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("not found", ex.Message);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.BadGateway)]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable)]
|
||||
[InlineData(HttpStatusCode.GatewayTimeout)]
|
||||
public async Task SendNotification_WhenServiceUnavailable_ThrowsGotifyException(HttpStatusCode statusCode)
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(statusCode);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("service unavailable", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenOtherError_ThrowsGotifyException()
|
||||
{
|
||||
// Arrange
|
||||
var proxy = CreateProxy();
|
||||
SetupErrorResponse(HttpStatusCode.InternalServerError);
|
||||
|
||||
// Act & Assert
|
||||
var ex = await Assert.ThrowsAsync<GotifyException>(() =>
|
||||
proxy.SendNotification(CreatePayload(), CreateConfig()));
|
||||
Assert.Contains("unable to send notification", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendNotification_WhenNetworkError_ThrowsGotifyException()
|
||||
{
|
||||
// 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<GotifyException>(() =>
|
||||
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
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -358,12 +358,9 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
|
||||
#region Provider Type Mapping Tests
|
||||
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -383,11 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -420,6 +413,8 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
NotificationProviderType.Ntfy => CreateNtfyConfig(name, isEnabled),
|
||||
NotificationProviderType.Pushover => CreatePushoverConfig(name, isEnabled),
|
||||
NotificationProviderType.Telegram => CreateTelegramConfig(name, isEnabled),
|
||||
NotificationProviderType.Discord => CreateDiscordConfig(name, isEnabled),
|
||||
NotificationProviderType.Gotify => CreateGotifyConfig(name, isEnabled),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(providerType))
|
||||
};
|
||||
}
|
||||
@@ -549,5 +544,60 @@ 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",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static NotificationConfig CreateGotifyConfig(string name, bool isEnabled)
|
||||
{
|
||||
return new NotificationConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = name,
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = isEnabled,
|
||||
OnStalledStrike = true,
|
||||
OnFailedImportStrike = true,
|
||||
OnSlowStrike = true,
|
||||
OnQueueItemDeleted = true,
|
||||
OnDownloadCleaned = true,
|
||||
OnCategoryChanged = true,
|
||||
GotifyConfiguration = new GotifyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "http://localhost:8000",
|
||||
ApplicationToken = "test_application_token",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
public static IEnumerable<object[]> NotificationProviderTypes =>
|
||||
[
|
||||
..Enum.GetValues<NotificationProviderType>()
|
||||
.Cast<Object>()
|
||||
.Select(x => new[] { x })
|
||||
.ToList()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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.Gotify;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
@@ -21,6 +23,8 @@ 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 Mock<IGotifyProxy> _gotifyProxyMock;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly NotificationProviderFactory _factory;
|
||||
|
||||
@@ -32,6 +36,8 @@ public class NotificationProviderFactoryTests
|
||||
_notifiarrProxyMock = new Mock<INotifiarrProxy>();
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
_telegramProxyMock = new Mock<ITelegramProxy>();
|
||||
_discordProxyMock = new Mock<IDiscordProxy>();
|
||||
_gotifyProxyMock = new Mock<IGotifyProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
@@ -40,6 +46,8 @@ public class NotificationProviderFactoryTests
|
||||
services.AddSingleton(_notifiarrProxyMock.Object);
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
services.AddSingleton(_telegramProxyMock.Object);
|
||||
services.AddSingleton(_discordProxyMock.Object);
|
||||
services.AddSingleton(_gotifyProxyMock.Object);
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new NotificationProviderFactory(_serviceProvider);
|
||||
@@ -194,6 +202,61 @@ 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_GotifyType_CreatesGotifyProvider()
|
||||
{
|
||||
// Arrange
|
||||
var config = new NotificationProviderDto
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = "TestGotify",
|
||||
Type = NotificationProviderType.Gotify,
|
||||
IsEnabled = true,
|
||||
Configuration = new GotifyConfig
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ServerUrl = "test-server-url",
|
||||
ApplicationToken = "test-application-token",
|
||||
}
|
||||
};
|
||||
|
||||
var provider = _factory.CreateProvider(config);
|
||||
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType<GotifyProvider>(provider);
|
||||
Assert.Equal("TestGotify", provider.Name);
|
||||
Assert.Equal(NotificationProviderType.Gotify, provider.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateProvider_UnsupportedType_ThrowsNotSupportedException()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
public class GotifyException : Exception
|
||||
{
|
||||
public GotifyException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public GotifyException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
public class GotifyPayload
|
||||
{
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
public int Priority { get; set; } = 5;
|
||||
|
||||
public GotifyExtras? Extras { get; set; }
|
||||
}
|
||||
|
||||
public class GotifyExtras
|
||||
{
|
||||
[JsonProperty("client::display")]
|
||||
public GotifyClientDisplay? ClientDisplay { get; set; }
|
||||
}
|
||||
|
||||
public class GotifyClientDisplay
|
||||
{
|
||||
public string? ContentType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
public sealed class GotifyProvider : NotificationProviderBase<GotifyConfig>
|
||||
{
|
||||
private readonly IGotifyProxy _proxy;
|
||||
|
||||
public GotifyProvider(
|
||||
string name,
|
||||
NotificationProviderType type,
|
||||
GotifyConfig config,
|
||||
IGotifyProxy proxy)
|
||||
: base(name, type, config)
|
||||
{
|
||||
_proxy = proxy;
|
||||
}
|
||||
|
||||
public override async Task SendNotificationAsync(NotificationContext context)
|
||||
{
|
||||
var payload = BuildPayload(context);
|
||||
await _proxy.SendNotification(payload, Config);
|
||||
}
|
||||
|
||||
private GotifyPayload BuildPayload(NotificationContext context)
|
||||
{
|
||||
var message = BuildMessage(context);
|
||||
|
||||
return new GotifyPayload
|
||||
{
|
||||
Title = context.Title,
|
||||
Message = message,
|
||||
Priority = Config.Priority,
|
||||
Extras = new GotifyExtras
|
||||
{
|
||||
ClientDisplay = new GotifyClientDisplay
|
||||
{
|
||||
ContentType = "text/markdown"
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private string BuildMessage(NotificationContext context)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context.Description))
|
||||
{
|
||||
lines.Add(context.Description);
|
||||
}
|
||||
|
||||
foreach ((string key, string value) in context.Data)
|
||||
{
|
||||
lines.Add($"**{key}:** {value}");
|
||||
}
|
||||
|
||||
return string.Join("\n\n", lines);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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.Gotify;
|
||||
|
||||
public sealed class GotifyProxy : IGotifyProxy
|
||||
{
|
||||
private readonly ILogger<GotifyProxy> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public GotifyProxy(ILogger<GotifyProxy> logger, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClientFactory.CreateClient(Constants.HttpClientWithRetryName);
|
||||
}
|
||||
|
||||
public async Task SendNotification(GotifyPayload payload, GotifyConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
string baseUrl = config.ServerUrl.TrimEnd('/');
|
||||
string url = $"{baseUrl}/message?token={config.ApplicationToken}";
|
||||
|
||||
string content = JsonConvert.SerializeObject(payload, new JsonSerializerSettings
|
||||
{
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver(),
|
||||
NullValueHandling = NullValueHandling.Ignore
|
||||
});
|
||||
|
||||
_logger.LogTrace("sending notification to Gotify: {content}", content);
|
||||
|
||||
using HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
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 GotifyException("unable to send notification", exception);
|
||||
}
|
||||
|
||||
switch ((int)exception.StatusCode)
|
||||
{
|
||||
case 401:
|
||||
case 403:
|
||||
throw new GotifyException("unable to send notification | application token is invalid or unauthorized");
|
||||
case 404:
|
||||
throw new GotifyException("unable to send notification | Gotify server not found");
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
throw new GotifyException("unable to send notification | Gotify service unavailable", exception);
|
||||
default:
|
||||
throw new GotifyException("unable to send notification", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
namespace Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
|
||||
public interface IGotifyProxy
|
||||
{
|
||||
Task SendNotification(GotifyPayload payload, GotifyConfig config);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
.Include(p => p.NtfyConfiguration)
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -139,6 +141,8 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
NotificationProviderType.Ntfy => config.NtfyConfiguration,
|
||||
NotificationProviderType.Pushover => config.PushoverConfiguration,
|
||||
NotificationProviderType.Telegram => config.TelegramConfiguration,
|
||||
NotificationProviderType.Discord => config.DiscordConfiguration,
|
||||
NotificationProviderType.Gotify => config.GotifyConfiguration,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(config), $"Config type for provider type {config.Type.ToString()} is not registered")
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Pushover;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Telegram;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
@@ -29,6 +31,8 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
|
||||
NotificationProviderType.Ntfy => CreateNtfyProvider(config),
|
||||
NotificationProviderType.Pushover => CreatePushoverProvider(config),
|
||||
NotificationProviderType.Telegram => CreateTelegramProvider(config),
|
||||
NotificationProviderType.Discord => CreateDiscordProvider(config),
|
||||
NotificationProviderType.Gotify => CreateGotifyProvider(config),
|
||||
_ => throw new NotSupportedException($"Provider type {config.Type} is not supported")
|
||||
};
|
||||
}
|
||||
@@ -73,4 +77,20 @@ 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);
|
||||
}
|
||||
|
||||
private INotificationProvider CreateGotifyProvider(NotificationProviderDto config)
|
||||
{
|
||||
var gotifyConfig = (GotifyConfig)config.Configuration;
|
||||
var proxy = _serviceProvider.GetRequiredService<IGotifyProxy>();
|
||||
|
||||
return new GotifyProvider(config.Name, config.Type, gotifyConfig, proxy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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 GotifyConfigTests
|
||||
{
|
||||
#region IsValid Tests
|
||||
|
||||
[Fact]
|
||||
public void IsValid_WithValidConfig_ReturnsTrue()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-app-token"
|
||||
};
|
||||
|
||||
config.IsValid().ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsValid_WithEmptyOrNullServerUrl_ReturnsFalse(string? serverUrl)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = serverUrl ?? string.Empty,
|
||||
ApplicationToken = "test-token"
|
||||
};
|
||||
|
||||
config.IsValid().ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void IsValid_WithEmptyOrNullApplicationToken_ReturnsFalse(string? token)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = token ?? string.Empty
|
||||
};
|
||||
|
||||
config.IsValid().ShouldBeFalse();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Validate Tests
|
||||
|
||||
[Fact]
|
||||
public void Validate_WithValidConfig_DoesNotThrow()
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-app-token",
|
||||
Priority = 5
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_WithEmptyOrNullServerUrl_ThrowsValidationException(string? serverUrl)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = serverUrl ?? string.Empty,
|
||||
ApplicationToken = "test-token"
|
||||
};
|
||||
|
||||
var ex = Should.Throw<ValidationException>(() => config.Validate());
|
||||
ex.Message.ShouldContain("required");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("not-a-url")]
|
||||
[InlineData("ftp://gotify.example.com")]
|
||||
[InlineData("invalid://scheme")]
|
||||
public void Validate_WithInvalidServerUrl_ThrowsValidationException(string serverUrl)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = serverUrl,
|
||||
ApplicationToken = "test-token"
|
||||
};
|
||||
|
||||
var ex = Should.Throw<ValidationException>(() => config.Validate());
|
||||
ex.Message.ShouldContain("valid HTTP or HTTPS URL");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://gotify.example.com")]
|
||||
[InlineData("http://localhost:8080")]
|
||||
[InlineData("https://gotify.local:8443/")]
|
||||
public void Validate_WithValidServerUrls_DoesNotThrow(string serverUrl)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = serverUrl,
|
||||
ApplicationToken = "test-token"
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Validate_WithEmptyOrNullApplicationToken_ThrowsValidationException(string? token)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = token ?? string.Empty
|
||||
};
|
||||
|
||||
var ex = Should.Throw<ValidationException>(() => config.Validate());
|
||||
ex.Message.ShouldContain("required");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(11)]
|
||||
[InlineData(100)]
|
||||
public void Validate_WithInvalidPriority_ThrowsValidationException(int priority)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-token",
|
||||
Priority = priority
|
||||
};
|
||||
|
||||
var ex = Should.Throw<ValidationException>(() => config.Validate());
|
||||
ex.Message.ShouldContain("Priority");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(5)]
|
||||
[InlineData(10)]
|
||||
public void Validate_WithValidPriority_DoesNotThrow(int priority)
|
||||
{
|
||||
var config = new GotifyConfig
|
||||
{
|
||||
ServerUrl = "https://gotify.example.com",
|
||||
ApplicationToken = "test-token",
|
||||
Priority = priority
|
||||
};
|
||||
|
||||
Should.NotThrow(() => config.Validate());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Default Values Tests
|
||||
|
||||
[Fact]
|
||||
public void NewConfig_HasDefaultPriorityOf5()
|
||||
{
|
||||
var config = new GotifyConfig();
|
||||
|
||||
config.Priority.ShouldBe(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NewConfig_HasEmptyStringsForRequiredFields()
|
||||
{
|
||||
var config = new GotifyConfig();
|
||||
|
||||
config.ServerUrl.ShouldBe(string.Empty);
|
||||
config.ApplicationToken.ShouldBe(string.Empty);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -56,6 +56,10 @@ public class DataContext : DbContext
|
||||
|
||||
public DbSet<TelegramConfig> TelegramConfigs { get; set; }
|
||||
|
||||
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
|
||||
|
||||
public DbSet<GotifyConfig> GotifyConfigs { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
|
||||
@@ -156,6 +160,16 @@ 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.HasOne(p => p.GotifyConfiguration)
|
||||
.WithOne(c => c.NotificationConfig)
|
||||
.HasForeignKey<GotifyConfig>(c => c.NotificationConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasIndex(p => p.Name).IsUnique();
|
||||
});
|
||||
|
||||
|
||||
1218
code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.Designer.cs
generated
Normal file
1218
code/backend/Cleanuparr.Persistence/Migrations/Data/20260112102214_AddDiscord.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
1269
code/backend/Cleanuparr.Persistence/Migrations/Data/20260112134008_AddGotify.Designer.cs
generated
Normal file
1269
code/backend/Cleanuparr.Persistence/Migrations/Data/20260112134008_AddGotify.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddGotify : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "gotify_configs",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
notification_config_id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
server_url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
application_token = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
priority = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_gotify_configs", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_gotify_configs_notification_configs_notification_config_id",
|
||||
column: x => x.notification_config_id,
|
||||
principalTable: "notification_configs",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_gotify_configs_notification_config_id",
|
||||
table: "gotify_configs",
|
||||
column: "notification_config_id",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "gotify_configs");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,82 @@ 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.GotifyConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ApplicationToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("application_token");
|
||||
|
||||
b.Property<Guid>("NotificationConfigId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notification_config_id");
|
||||
|
||||
b.Property<int>("Priority")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("priority");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_gotify_configs");
|
||||
|
||||
b.HasIndex("NotificationConfigId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_gotify_configs_notification_config_id");
|
||||
|
||||
b.ToTable("gotify_configs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Cleanuparr.Persistence.Models.Configuration.Notification.NotifiarrConfig", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -813,7 +889,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 +1119,30 @@ 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.GotifyConfig", b =>
|
||||
{
|
||||
b.HasOne("Cleanuparr.Persistence.Models.Configuration.Notification.NotificationConfig", "NotificationConfig")
|
||||
.WithOne("GotifyConfiguration")
|
||||
.HasForeignKey("Cleanuparr.Persistence.Models.Configuration.Notification.GotifyConfig", "NotificationConfigId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_gotify_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 +1241,10 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
{
|
||||
b.Navigation("AppriseConfiguration");
|
||||
|
||||
b.Navigation("DiscordConfiguration");
|
||||
|
||||
b.Navigation("GotifyConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
|
||||
b.Navigation("NtfyConfiguration");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
using ValidationException = Cleanuparr.Domain.Exceptions.ValidationException;
|
||||
|
||||
namespace Cleanuparr.Persistence.Models.Configuration.Notification;
|
||||
|
||||
public sealed record GotifyConfig : 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 ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
|
||||
public bool IsValid()
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(ServerUrl) && !string.IsNullOrWhiteSpace(ApplicationToken);
|
||||
}
|
||||
|
||||
public void Validate()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ServerUrl))
|
||||
{
|
||||
throw new ValidationException("Gotify server URL is required");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var uri) ||
|
||||
(uri.Scheme != "http" && uri.Scheme != "https"))
|
||||
{
|
||||
throw new ValidationException("Gotify server URL must be a valid HTTP or HTTPS URL");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ApplicationToken))
|
||||
{
|
||||
throw new ValidationException("Gotify application token is required");
|
||||
}
|
||||
|
||||
if (Priority < 0 || Priority > 10)
|
||||
{
|
||||
throw new ValidationException("Priority must be between 0 and 10");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,10 @@ public sealed record NotificationConfig
|
||||
|
||||
public TelegramConfig? TelegramConfiguration { get; init; }
|
||||
|
||||
public DiscordConfig? DiscordConfiguration { get; init; }
|
||||
|
||||
public GotifyConfig? GotifyConfiguration { get; init; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsConfigured => Type switch
|
||||
{
|
||||
@@ -53,6 +57,8 @@ public sealed record NotificationConfig
|
||||
NotificationProviderType.Ntfy => NtfyConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Pushover => PushoverConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Telegram => TelegramConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Discord => DiscordConfiguration?.IsValid() == true,
|
||||
NotificationProviderType.Gotify => GotifyConfiguration?.IsValid() == true,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(Type), $"Invalid notification provider type {Type}")
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
62
code/frontend/package-lock.json
generated
62
code/frontend/package-lock.json
generated
@@ -439,7 +439,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/animations/-/animations-19.2.17.tgz",
|
||||
"integrity": "sha512-6VTet2fzTpSHEjxcVVzL8ZIyNGo/qsUs4XF/3wh9Iwu6qfWx711qXKlqGD/IHWzMTumzvQXbTV4hzvnO7fJvIQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -542,7 +541,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-19.2.19.tgz",
|
||||
"integrity": "sha512-PCpJagurPBqciqcq4Z8+3OtKLb7rSl4w/qBJoIMua8CgnrjvA1i+SWawhdtfI1zlY8FSwhzLwXV0CmWWfFzQPg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"parse5": "^7.1.2",
|
||||
"tslib": "^2.3.0"
|
||||
@@ -592,7 +590,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-19.2.17.tgz",
|
||||
"integrity": "sha512-yFUXAdpvOFirGD/EGDwp1WHravHzI4sdyRE2iH7i8im9l8IE2VZ6D1KDJp8VVpMJt38LNlRAWYek3s+z6OcAkg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -609,7 +606,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.17.tgz",
|
||||
"integrity": "sha512-qo8psYASAlDiQ8fAL8i/E2JfWH2nPTpZDKKZxSWvgBVA8o+zUEjYAJu6/k6btnu+4Qcb425T0rmM/zao6EU9Aw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -623,7 +619,6 @@
|
||||
"integrity": "sha512-KG82fh2A0odttc6+FxlQmFfHY/Giq8rYeV1qtdafafJ8hdWIiMr4r37xwhZOl8uk2/XSLM66bxUMFHYm+zt87Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "7.26.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@@ -700,7 +695,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-19.2.17.tgz",
|
||||
"integrity": "sha512-nVu0ryxfiXUZ9M+NV21TY+rJZkPXTYo9U0aJb19hvByPpG+EvuujXUOgpulz6vxIzGy7pz/znRa+K9kxuuC+yQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -717,7 +711,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-19.2.17.tgz",
|
||||
"integrity": "sha512-INgGGmMbwXuT+niAjMiCsJrZVEGWKZOep1vCRHoKlVnGUQSRKc3UW8ztmKDKMua/io/Opi03pRMpwbYQcTBr5A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -736,7 +729,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-19.2.17.tgz",
|
||||
"integrity": "sha512-Rn23nIQwYMSeGXWFHI/X8bGHAkdahRxH9UIGUlJKxW61MSkK6AW4kCHG/Ev1TvDq9HjijsMjcqcsd6/Sb8aBXg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -777,7 +769,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-19.2.17.tgz",
|
||||
"integrity": "sha512-B3Vk+E8UHQwg06WEjGuvYaKNiIXxjHN9pN8S+hDE8xwRgIS5ojEwS94blEvsGQ4QsIja6WjZMOfDUBUPlgUSuA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -796,7 +787,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@angular/service-worker/-/service-worker-19.2.17.tgz",
|
||||
"integrity": "sha512-rwki6a0PBm4biGG+BnnHiALPfAnTUqebjyA5fRCJ+cbh4nYvJZ2nU9GyHc56WwgfhwXWDo3UOxB+8K0INTv2RA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@@ -842,7 +832,6 @@
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
@@ -3450,7 +3439,6 @@
|
||||
"integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@inquirer/checkbox": "^4.1.2",
|
||||
"@inquirer/confirm": "^5.1.6",
|
||||
@@ -5706,7 +5694,6 @@
|
||||
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -5955,7 +5942,6 @@
|
||||
"integrity": "sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.32.1",
|
||||
"@typescript-eslint/types": "8.32.1",
|
||||
@@ -6266,7 +6252,6 @@
|
||||
"integrity": "sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -6309,7 +6294,6 @@
|
||||
"integrity": "sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.7.0",
|
||||
"@typescript-eslint/scope-manager": "8.51.0",
|
||||
@@ -6606,7 +6590,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6669,7 +6652,6 @@
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@@ -7198,7 +7180,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -8347,6 +8328,29 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.5",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz",
|
||||
@@ -8627,7 +8631,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10445,8 +10448,7 @@
|
||||
"resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.6.0.tgz",
|
||||
"integrity": "sha512-niVlkeYVRwKFpmfWg6suo6H9CrNnydfBLEqefM5UjibYS+UoTjZdmvPJSiuyrRLGnFj1eYRhFd/ch+5hSlsFVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jest-worker": {
|
||||
"version": "27.5.1",
|
||||
@@ -10485,7 +10487,6 @@
|
||||
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"jiti": "bin/jiti.js"
|
||||
}
|
||||
@@ -10600,7 +10601,6 @@
|
||||
"integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "1.5.0",
|
||||
"body-parser": "^1.19.0",
|
||||
@@ -11020,7 +11020,6 @@
|
||||
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"copy-anything": "^2.0.1",
|
||||
"parse-node-version": "^1.0.1",
|
||||
@@ -13001,7 +13000,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -13727,7 +13725,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
@@ -14913,7 +14910,6 @@
|
||||
"integrity": "sha512-LBAhFyLho16harJoWMg/nZsQYgTrg5jXOn2nCYjRUcZZEdE3qa2zb8QEDRUGVZBW4rlazf2fxkg8tztybTaqWw==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/source-map": "^0.3.3",
|
||||
"acorn": "^8.8.2",
|
||||
@@ -15132,8 +15128,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tuf-js": {
|
||||
"version": "3.1.0",
|
||||
@@ -15190,7 +15185,6 @@
|
||||
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15205,7 +15199,6 @@
|
||||
"integrity": "sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.32.1",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
@@ -16041,7 +16034,6 @@
|
||||
"integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.6",
|
||||
@@ -16119,7 +16111,6 @@
|
||||
"integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/bonjour": "^3.5.13",
|
||||
"@types/connect-history-api-fallback": "^1.5.4",
|
||||
@@ -16732,8 +16723,7 @@
|
||||
"version": "0.15.1",
|
||||
"resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz",
|
||||
"integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
code/frontend/public/icons/ext/discord-light.svg
Normal file
1
code/frontend/public/icons/ext/discord-light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 766 B |
1
code/frontend/public/icons/ext/discord.svg
Normal file
1
code/frontend/public/icons/ext/discord.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 512 512"><path d="M433.7 91a416.5 416.5 0 0 0-105.6-33.2c-4.6 8.2-9.9 19.3-13.5 28.1-39.4-5.9-78.4-5.9-117.1 0-3.7-8.8-9.1-19.9-13.7-28.1-37.1 6.4-72.6 17.7-105.7 33.3-66.8 101-85 199.5-75.9 296.6 44.3 33.1 87.3 53.2 129.6 66.4 10.4-14.4 19.7-29.6 27.7-45.7-15.3-5.8-29.9-13-43.7-21.3 3.7-2.7 7.2-5.6 10.7-8.5 84.2 39.4 175.8 39.4 259 0 3.5 2.9 7.1 5.8 10.7 8.5-13.9 8.3-28.5 15.5-43.8 21.3 8 16 17.3 31.3 27.7 45.7 42.3-13.2 85.3-33.3 129.6-66.4 10.8-112.5-18-210.1-76-296.7M170.9 328c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c.1 28.8-20.2 52.4-46 52.4m170.2 0c-25.3 0-46-23.6-46-52.4s20.3-52.4 46-52.4 46.5 23.6 46 52.4c0 28.8-20.3 52.4-46 52.4" style="fill:#5865f2"/></svg>
|
||||
|
After Width: | Height: | Size: 769 B |
1
code/frontend/public/icons/ext/gotify-light.svg
Normal file
1
code/frontend/public/icons/ext/gotify-light.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
1
code/frontend/public/icons/ext/gotify.svg
Normal file
1
code/frontend/public/icons/ext/gotify.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 11 KiB |
@@ -152,6 +152,16 @@ export class DocumentationService {
|
||||
'telegram.topicId': 'topic-id',
|
||||
'telegram.sendSilently': 'send-silently'
|
||||
},
|
||||
'notifications/discord': {
|
||||
'discord.webhookUrl': 'webhook-url',
|
||||
'discord.username': 'username',
|
||||
'discord.avatarUrl': 'avatar-url'
|
||||
},
|
||||
'notifications/gotify': {
|
||||
'gotify.serverUrl': 'server-url',
|
||||
'gotify.applicationToken': 'application-token',
|
||||
'gotify.priority': 'priority'
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private applicationPathService: ApplicationPathService) {}
|
||||
|
||||
@@ -230,6 +230,74 @@ export interface TestTelegramProviderRequest {
|
||||
sendSilently: boolean;
|
||||
}
|
||||
|
||||
export interface CreateDiscordProviderRequest {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface UpdateDiscordProviderRequest {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface TestDiscordProviderRequest {
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface CreateGotifyProviderRequest {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
serverUrl: string;
|
||||
applicationToken: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface UpdateGotifyProviderRequest {
|
||||
name: string;
|
||||
isEnabled: boolean;
|
||||
onFailedImportStrike: boolean;
|
||||
onStalledStrike: boolean;
|
||||
onSlowStrike: boolean;
|
||||
onQueueItemDeleted: boolean;
|
||||
onDownloadCleaned: boolean;
|
||||
onCategoryChanged: boolean;
|
||||
serverUrl: string;
|
||||
applicationToken: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
export interface TestGotifyProviderRequest {
|
||||
serverUrl: string;
|
||||
applicationToken: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -287,6 +355,20 @@ export class NotificationProviderService {
|
||||
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/telegram`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Discord provider
|
||||
*/
|
||||
createDiscordProvider(provider: CreateDiscordProviderRequest): Observable<NotificationProviderDto> {
|
||||
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/discord`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Gotify provider
|
||||
*/
|
||||
createGotifyProvider(provider: CreateGotifyProviderRequest): Observable<NotificationProviderDto> {
|
||||
return this.http.post<NotificationProviderDto>(`${this.baseUrl}/gotify`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Notifiarr provider
|
||||
*/
|
||||
@@ -322,6 +404,20 @@ export class NotificationProviderService {
|
||||
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/telegram/${id}`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Discord provider
|
||||
*/
|
||||
updateDiscordProvider(id: string, provider: UpdateDiscordProviderRequest): Observable<NotificationProviderDto> {
|
||||
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/discord/${id}`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing Gotify provider
|
||||
*/
|
||||
updateGotifyProvider(id: string, provider: UpdateGotifyProviderRequest): Observable<NotificationProviderDto> {
|
||||
return this.http.put<NotificationProviderDto>(`${this.baseUrl}/gotify/${id}`, provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a notification provider
|
||||
*/
|
||||
@@ -364,6 +460,20 @@ export class NotificationProviderService {
|
||||
return this.http.post<TestNotificationResult>(`${this.baseUrl}/telegram/test`, testRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a Discord provider (without ID - for testing configuration before saving)
|
||||
*/
|
||||
testDiscordProvider(testRequest: TestDiscordProviderRequest): Observable<TestNotificationResult> {
|
||||
return this.http.post<TestNotificationResult>(`${this.baseUrl}/discord/test`, testRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a Gotify provider (without ID - for testing configuration before saving)
|
||||
*/
|
||||
testGotifyProvider(testRequest: TestGotifyProviderRequest): Observable<TestNotificationResult> {
|
||||
return this.http.post<TestNotificationResult>(`${this.baseUrl}/gotify/test`, testRequest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic create method that delegates to provider-specific methods
|
||||
*/
|
||||
@@ -379,6 +489,10 @@ export class NotificationProviderService {
|
||||
return this.createPushoverProvider(provider as CreatePushoverProviderRequest);
|
||||
case NotificationProviderType.Telegram:
|
||||
return this.createTelegramProvider(provider as CreateTelegramProviderRequest);
|
||||
case NotificationProviderType.Discord:
|
||||
return this.createDiscordProvider(provider as CreateDiscordProviderRequest);
|
||||
case NotificationProviderType.Gotify:
|
||||
return this.createGotifyProvider(provider as CreateGotifyProviderRequest);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
@@ -399,6 +513,10 @@ export class NotificationProviderService {
|
||||
return this.updatePushoverProvider(id, provider as UpdatePushoverProviderRequest);
|
||||
case NotificationProviderType.Telegram:
|
||||
return this.updateTelegramProvider(id, provider as UpdateTelegramProviderRequest);
|
||||
case NotificationProviderType.Discord:
|
||||
return this.updateDiscordProvider(id, provider as UpdateDiscordProviderRequest);
|
||||
case NotificationProviderType.Gotify:
|
||||
return this.updateGotifyProvider(id, provider as UpdateGotifyProviderRequest);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
@@ -419,6 +537,10 @@ export class NotificationProviderService {
|
||||
return this.testPushoverProvider(testRequest as TestPushoverProviderRequest);
|
||||
case NotificationProviderType.Telegram:
|
||||
return this.testTelegramProvider(testRequest as TestTelegramProviderRequest);
|
||||
case NotificationProviderType.Discord:
|
||||
return this.testDiscordProvider(testRequest as TestDiscordProviderRequest);
|
||||
case NotificationProviderType.Gotify:
|
||||
return this.testGotifyProvider(testRequest as TestGotifyProviderRequest);
|
||||
default:
|
||||
throw new Error(`Unsupported provider type: ${type}`);
|
||||
}
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
optionValue="value"
|
||||
placeholder="Select version"
|
||||
styleClass="w-full"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<app-notification-provider-base
|
||||
[visible]="visible"
|
||||
modalTitle="Configure Discord Provider"
|
||||
[saving]="saving"
|
||||
[testing]="testing"
|
||||
[editingProvider]="editingProvider"
|
||||
(save)="onSave($event)"
|
||||
(cancel)="onCancel()"
|
||||
(test)="onTest($event)">
|
||||
|
||||
<!-- Provider-specific configuration goes here -->
|
||||
<div slot="provider-config">
|
||||
<!-- Webhook URL Field -->
|
||||
<div class="field">
|
||||
<label for="webhook-url">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('discord.webhookUrl')"></i>
|
||||
Webhook URL *
|
||||
</label>
|
||||
<input
|
||||
id="webhook-url"
|
||||
type="password"
|
||||
pInputText
|
||||
[formControl]="webhookUrlControl"
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(webhookUrlControl, 'required')" class="form-error-text">Webhook URL is required</small>
|
||||
<small *ngIf="hasFieldError(webhookUrlControl, 'pattern')" class="form-error-text">Must be a valid Discord webhook URL</small>
|
||||
<small class="form-helper-text">Your Discord webhook URL. Create one in your Discord server's channel settings under Integrations.</small>
|
||||
</div>
|
||||
|
||||
<!-- Username Field (Optional) -->
|
||||
<div class="field">
|
||||
<label for="username">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('discord.username')"></i>
|
||||
Username (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
pInputText
|
||||
[formControl]="usernameControl"
|
||||
placeholder="Cleanuparr"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(usernameControl, 'maxlength')" class="form-error-text">Username cannot exceed 80 characters</small>
|
||||
<small class="form-helper-text">Override the default webhook username. Leave empty to use the webhook's default name.</small>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL Field (Optional) -->
|
||||
<div class="field">
|
||||
<label for="avatar-url">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('discord.avatarUrl')"></i>
|
||||
Avatar URL (Optional)
|
||||
</label>
|
||||
<input
|
||||
id="avatar-url"
|
||||
type="text"
|
||||
pInputText
|
||||
[formControl]="avatarUrlControl"
|
||||
placeholder="https://example.com/avatar.png"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(avatarUrlControl, 'pattern')" class="form-error-text">Must be a valid URL</small>
|
||||
<small class="form-helper-text">Override the default webhook avatar. Leave empty to use the webhook's default avatar.</small>
|
||||
</div>
|
||||
</div>
|
||||
</app-notification-provider-base>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../../styles/settings-shared.scss';
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { DiscordFormData, BaseProviderFormData } from '../../models/provider-modal.model';
|
||||
import { DocumentationService } from '../../../../core/services/documentation.service';
|
||||
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
|
||||
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-discord-provider',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
InputTextModule,
|
||||
NotificationProviderBaseComponent
|
||||
],
|
||||
templateUrl: './discord-provider.component.html',
|
||||
styleUrls: ['./discord-provider.component.scss']
|
||||
})
|
||||
export class DiscordProviderComponent implements OnInit, OnChanges {
|
||||
@Input() visible = false;
|
||||
@Input() editingProvider: NotificationProviderDto | null = null;
|
||||
@Input() saving = false;
|
||||
@Input() testing = false;
|
||||
|
||||
@Output() save = new EventEmitter<DiscordFormData>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
@Output() test = new EventEmitter<DiscordFormData>();
|
||||
|
||||
// Provider-specific form controls
|
||||
webhookUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\/.+/)]);
|
||||
usernameControl = new FormControl('', [Validators.maxLength(80)]);
|
||||
avatarUrlControl = new FormControl('', [Validators.pattern(/^(https?:\/\/.+)?$/)]);
|
||||
|
||||
private documentationService = inject(DocumentationService);
|
||||
|
||||
/** Exposed for template to open documentation for discord fields */
|
||||
openFieldDocs(fieldName: string): void {
|
||||
this.documentationService.openFieldDocumentation('notifications/discord', fieldName);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component but don't populate yet - wait for ngOnChanges
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Populate provider-specific fields when editingProvider input changes
|
||||
if (changes['editingProvider']) {
|
||||
if (this.editingProvider) {
|
||||
this.populateProviderFields();
|
||||
} else {
|
||||
// Reset fields when editingProvider is cleared
|
||||
this.resetProviderFields();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private populateProviderFields(): void {
|
||||
if (this.editingProvider) {
|
||||
const config = this.editingProvider.configuration as any;
|
||||
|
||||
this.webhookUrlControl.setValue(config?.webhookUrl || '');
|
||||
this.usernameControl.setValue(config?.username || '');
|
||||
this.avatarUrlControl.setValue(config?.avatarUrl || '');
|
||||
}
|
||||
}
|
||||
|
||||
private resetProviderFields(): void {
|
||||
this.webhookUrlControl.setValue('');
|
||||
this.usernameControl.setValue('');
|
||||
this.avatarUrlControl.setValue('https://github.com/Cleanuparr/Cleanuparr/blob/main/Logo/48.png?raw=true');
|
||||
}
|
||||
|
||||
protected hasFieldError(control: FormControl, errorType: string): boolean {
|
||||
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
|
||||
}
|
||||
|
||||
onSave(baseData: BaseProviderFormData): void {
|
||||
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
|
||||
const discordData: DiscordFormData = {
|
||||
...baseData,
|
||||
webhookUrl: this.webhookUrlControl.value || '',
|
||||
username: this.usernameControl.value || '',
|
||||
avatarUrl: this.avatarUrlControl.value || ''
|
||||
};
|
||||
this.save.emit(discordData);
|
||||
} else {
|
||||
// Mark provider-specific fields as touched to show validation errors
|
||||
this.webhookUrlControl.markAsTouched();
|
||||
this.usernameControl.markAsTouched();
|
||||
this.avatarUrlControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onTest(baseData: BaseProviderFormData): void {
|
||||
if (this.webhookUrlControl.valid && this.usernameControl.valid && this.avatarUrlControl.valid) {
|
||||
const discordData: DiscordFormData = {
|
||||
...baseData,
|
||||
webhookUrl: this.webhookUrlControl.value || '',
|
||||
username: this.usernameControl.value || '',
|
||||
avatarUrl: this.avatarUrlControl.value || ''
|
||||
};
|
||||
this.test.emit(discordData);
|
||||
} else {
|
||||
// Mark provider-specific fields as touched to show validation errors
|
||||
this.webhookUrlControl.markAsTouched();
|
||||
this.usernameControl.markAsTouched();
|
||||
this.avatarUrlControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<app-notification-provider-base
|
||||
[visible]="visible"
|
||||
modalTitle="Configure Gotify Provider"
|
||||
[saving]="saving"
|
||||
[testing]="testing"
|
||||
[editingProvider]="editingProvider"
|
||||
(save)="onSave($event)"
|
||||
(cancel)="onCancel()"
|
||||
(test)="onTest($event)">
|
||||
|
||||
<!-- Provider-specific configuration goes here -->
|
||||
<div slot="provider-config">
|
||||
<!-- Server URL Field -->
|
||||
<div class="field">
|
||||
<label for="server-url">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('gotify.serverUrl')"></i>
|
||||
Server URL *
|
||||
</label>
|
||||
<input
|
||||
id="server-url"
|
||||
type="text"
|
||||
pInputText
|
||||
[formControl]="serverUrlControl"
|
||||
placeholder="https://gotify.example.com"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'required')" class="form-error-text">Server URL is required</small>
|
||||
<small *ngIf="hasFieldError(serverUrlControl, 'pattern')" class="form-error-text">Must be a valid HTTP or HTTPS URL</small>
|
||||
<small class="form-helper-text">The URL of your Gotify server instance.</small>
|
||||
</div>
|
||||
|
||||
<!-- Application Token Field -->
|
||||
<div class="field">
|
||||
<label for="application-token">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('gotify.applicationToken')"></i>
|
||||
Application Token *
|
||||
</label>
|
||||
<input
|
||||
id="application-token"
|
||||
type="password"
|
||||
pInputText
|
||||
[formControl]="applicationTokenControl"
|
||||
placeholder="Your Gotify application token"
|
||||
class="w-full" />
|
||||
<small *ngIf="hasFieldError(applicationTokenControl, 'required')" class="form-error-text">Application token is required</small>
|
||||
<small class="form-helper-text">Create an application in your Gotify server and use its token here.</small>
|
||||
</div>
|
||||
|
||||
<!-- Priority Field -->
|
||||
<div class="field">
|
||||
<label for="priority">
|
||||
<i class="pi pi-question-circle field-info-icon"
|
||||
title="Click for documentation"
|
||||
(click)="openFieldDocs('gotify.priority')"></i>
|
||||
Priority
|
||||
</label>
|
||||
<p-inputNumber
|
||||
id="priority"
|
||||
[formControl]="priorityControl"
|
||||
[min]="0"
|
||||
[max]="10"
|
||||
[showButtons]="true"
|
||||
styleClass="w-full">
|
||||
</p-inputNumber>
|
||||
<small *ngIf="hasFieldError(priorityControl, 'min') || hasFieldError(priorityControl, 'max')" class="form-error-text">Priority must be between 0 and 10</small>
|
||||
<small class="form-helper-text">Message priority (0-10). Higher values may trigger different notification behaviors on clients.</small>
|
||||
</div>
|
||||
</div>
|
||||
</app-notification-provider-base>
|
||||
@@ -0,0 +1 @@
|
||||
@use '../../../styles/settings-shared.scss';
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Component, Input, Output, EventEmitter, OnInit, OnChanges, SimpleChanges, inject } from '@angular/core';
|
||||
import { FormControl, Validators, ReactiveFormsModule } from '@angular/forms';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { InputNumberModule } from 'primeng/inputnumber';
|
||||
import { GotifyFormData, BaseProviderFormData } from '../../models/provider-modal.model';
|
||||
import { DocumentationService } from '../../../../core/services/documentation.service';
|
||||
import { NotificationProviderDto } from '../../../../shared/models/notification-provider.model';
|
||||
import { NotificationProviderBaseComponent } from '../base/notification-provider-base.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-gotify-provider',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
InputTextModule,
|
||||
InputNumberModule,
|
||||
NotificationProviderBaseComponent
|
||||
],
|
||||
templateUrl: './gotify-provider.component.html',
|
||||
styleUrls: ['./gotify-provider.component.scss']
|
||||
})
|
||||
export class GotifyProviderComponent implements OnInit, OnChanges {
|
||||
@Input() visible = false;
|
||||
@Input() editingProvider: NotificationProviderDto | null = null;
|
||||
@Input() saving = false;
|
||||
@Input() testing = false;
|
||||
|
||||
@Output() save = new EventEmitter<GotifyFormData>();
|
||||
@Output() cancel = new EventEmitter<void>();
|
||||
@Output() test = new EventEmitter<GotifyFormData>();
|
||||
|
||||
// Provider-specific form controls
|
||||
serverUrlControl = new FormControl('', [Validators.required, Validators.pattern(/^https?:\/\/.+/)]);
|
||||
applicationTokenControl = new FormControl('', [Validators.required]);
|
||||
priorityControl = new FormControl(5, [Validators.required, Validators.min(0), Validators.max(10)]);
|
||||
|
||||
private documentationService = inject(DocumentationService);
|
||||
|
||||
/** Exposed for template to open documentation for gotify fields */
|
||||
openFieldDocs(fieldName: string): void {
|
||||
this.documentationService.openFieldDocumentation('notifications/gotify', fieldName);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Initialize component but don't populate yet - wait for ngOnChanges
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
// Populate provider-specific fields when editingProvider input changes
|
||||
if (changes['editingProvider']) {
|
||||
if (this.editingProvider) {
|
||||
this.populateProviderFields();
|
||||
} else {
|
||||
// Reset fields when editingProvider is cleared
|
||||
this.resetProviderFields();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private populateProviderFields(): void {
|
||||
if (this.editingProvider) {
|
||||
const config = this.editingProvider.configuration as any;
|
||||
|
||||
this.serverUrlControl.setValue(config?.serverUrl || '');
|
||||
this.applicationTokenControl.setValue(config?.applicationToken || '');
|
||||
this.priorityControl.setValue(config?.priority ?? 5);
|
||||
}
|
||||
}
|
||||
|
||||
private resetProviderFields(): void {
|
||||
this.serverUrlControl.setValue('');
|
||||
this.applicationTokenControl.setValue('');
|
||||
this.priorityControl.setValue(5);
|
||||
}
|
||||
|
||||
protected hasFieldError(control: FormControl, errorType: string): boolean {
|
||||
return !!(control && control.errors?.[errorType] && (control.dirty || control.touched));
|
||||
}
|
||||
|
||||
onSave(baseData: BaseProviderFormData): void {
|
||||
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
|
||||
const gotifyData: GotifyFormData = {
|
||||
...baseData,
|
||||
serverUrl: this.serverUrlControl.value || '',
|
||||
applicationToken: this.applicationTokenControl.value || '',
|
||||
priority: this.priorityControl.value ?? 5
|
||||
};
|
||||
this.save.emit(gotifyData);
|
||||
} else {
|
||||
// Mark provider-specific fields as touched to show validation errors
|
||||
this.serverUrlControl.markAsTouched();
|
||||
this.applicationTokenControl.markAsTouched();
|
||||
this.priorityControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
|
||||
onCancel(): void {
|
||||
this.cancel.emit();
|
||||
}
|
||||
|
||||
onTest(baseData: BaseProviderFormData): void {
|
||||
if (this.serverUrlControl.valid && this.applicationTokenControl.valid && this.priorityControl.valid) {
|
||||
const gotifyData: GotifyFormData = {
|
||||
...baseData,
|
||||
serverUrl: this.serverUrlControl.value || '',
|
||||
applicationToken: this.applicationTokenControl.value || '',
|
||||
priority: this.priorityControl.value ?? 5
|
||||
};
|
||||
this.test.emit(gotifyData);
|
||||
} else {
|
||||
// Mark provider-specific fields as touched to show validation errors
|
||||
this.serverUrlControl.markAsTouched();
|
||||
this.applicationTokenControl.markAsTouched();
|
||||
this.priorityControl.markAsTouched();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,8 +57,22 @@ export class ProviderTypeSelectionComponent {
|
||||
iconUrl: 'icons/ext/telegram-light.svg',
|
||||
iconUrlHover: 'icons/ext/telegram.svg',
|
||||
description: 'https://core.telegram.org/bots'
|
||||
}
|
||||
];
|
||||
},
|
||||
{
|
||||
type: NotificationProviderType.Discord,
|
||||
name: 'Discord',
|
||||
iconUrl: 'icons/ext/discord-light.svg',
|
||||
iconUrlHover: 'icons/ext/discord.svg',
|
||||
description: 'https://discord.com'
|
||||
},
|
||||
{
|
||||
type: NotificationProviderType.Gotify,
|
||||
name: 'Gotify',
|
||||
iconUrl: 'icons/ext/gotify-light.svg',
|
||||
iconUrlHover: 'icons/ext/gotify.svg',
|
||||
description: 'https://gotify.net'
|
||||
},
|
||||
].sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
selectProvider(type: NotificationProviderType) {
|
||||
this.providerSelected.emit(type);
|
||||
|
||||
@@ -72,6 +72,18 @@ export interface TelegramFormData extends BaseProviderFormData {
|
||||
sendSilently: boolean;
|
||||
}
|
||||
|
||||
export interface DiscordFormData extends BaseProviderFormData {
|
||||
webhookUrl: string;
|
||||
username: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface GotifyFormData extends BaseProviderFormData {
|
||||
serverUrl: string;
|
||||
applicationToken: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// Events for modal communication
|
||||
export interface ProviderModalEvents {
|
||||
save: (data: any) => void;
|
||||
|
||||
@@ -135,7 +135,6 @@
|
||||
<h3>Provider Type Not Yet Supported</h3>
|
||||
<p class="text-color-secondary">
|
||||
This provider type is not yet supported by the new modal system.
|
||||
<br>Please select Notifiarr or Apprise for now.
|
||||
</p>
|
||||
<button
|
||||
pButton
|
||||
@@ -209,6 +208,28 @@
|
||||
(test)="onTelegramTest($event)"
|
||||
></app-telegram-provider>
|
||||
|
||||
<!-- Discord Provider Modal -->
|
||||
<app-discord-provider
|
||||
[visible]="showDiscordModal"
|
||||
[editingProvider]="editingProvider"
|
||||
[saving]="saving()"
|
||||
[testing]="testing()"
|
||||
(save)="onDiscordSave($event)"
|
||||
(cancel)="onProviderCancel()"
|
||||
(test)="onDiscordTest($event)"
|
||||
></app-discord-provider>
|
||||
|
||||
<!-- Gotify Provider Modal -->
|
||||
<app-gotify-provider
|
||||
[visible]="showGotifyModal"
|
||||
[editingProvider]="editingProvider"
|
||||
[saving]="saving()"
|
||||
[testing]="testing()"
|
||||
(save)="onGotifySave($event)"
|
||||
(cancel)="onProviderCancel()"
|
||||
(test)="onGotifyTest($event)"
|
||||
></app-gotify-provider>
|
||||
|
||||
<!-- Confirmation Dialog -->
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "../../shared/models/notification-provider.model";
|
||||
import { NotificationProviderType } from "../../shared/models/enums";
|
||||
import { DocumentationService } from "../../core/services/documentation.service";
|
||||
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData } from "./models/provider-modal.model";
|
||||
import { NotifiarrFormData, AppriseFormData, NtfyFormData, PushoverFormData, TelegramFormData, DiscordFormData, GotifyFormData } from "./models/provider-modal.model";
|
||||
import { LoadingErrorStateComponent } from "../../shared/components/loading-error-state/loading-error-state.component";
|
||||
|
||||
// New modal components
|
||||
@@ -18,6 +18,8 @@ import { AppriseProviderComponent } from "./modals/apprise-provider/apprise-prov
|
||||
import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.component";
|
||||
import { PushoverProviderComponent } from "./modals/pushover-provider/pushover-provider.component";
|
||||
import { TelegramProviderComponent } from "./modals/telegram-provider/telegram-provider.component";
|
||||
import { DiscordProviderComponent } from "./modals/discord-provider/discord-provider.component";
|
||||
import { GotifyProviderComponent } from "./modals/gotify-provider/gotify-provider.component";
|
||||
|
||||
// PrimeNG Components
|
||||
import { CardModule } from "primeng/card";
|
||||
@@ -55,6 +57,8 @@ import { NotificationService } from "../../core/services/notification.service";
|
||||
NtfyProviderComponent,
|
||||
PushoverProviderComponent,
|
||||
TelegramProviderComponent,
|
||||
DiscordProviderComponent,
|
||||
GotifyProviderComponent,
|
||||
],
|
||||
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
|
||||
templateUrl: "./notification-settings.component.html",
|
||||
@@ -66,12 +70,14 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
|
||||
// Modal state
|
||||
showProviderModal = false; // Legacy modal for unsupported types
|
||||
showTypeSelectionModal = false; // New: Provider type selection modal
|
||||
showNotifiarrModal = false; // New: Notifiarr provider modal
|
||||
showAppriseModal = false; // New: Apprise provider modal
|
||||
showNtfyModal = false; // New: Ntfy provider modal
|
||||
showPushoverModal = false; // New: Pushover provider modal
|
||||
showTelegramModal = false; // New: Telegram provider modal
|
||||
showTypeSelectionModal = false;
|
||||
showNotifiarrModal = false;
|
||||
showAppriseModal = false;
|
||||
showNtfyModal = false;
|
||||
showPushoverModal = false;
|
||||
showTelegramModal = false;
|
||||
showDiscordModal = false;
|
||||
showGotifyModal = false;
|
||||
modalMode: 'add' | 'edit' = 'add';
|
||||
editingProvider: NotificationProviderDto | null = null;
|
||||
|
||||
@@ -186,6 +192,12 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
case NotificationProviderType.Telegram:
|
||||
this.showTelegramModal = true;
|
||||
break;
|
||||
case NotificationProviderType.Discord:
|
||||
this.showDiscordModal = true;
|
||||
break;
|
||||
case NotificationProviderType.Gotify:
|
||||
this.showGotifyModal = true;
|
||||
break;
|
||||
default:
|
||||
// For unsupported types, show the legacy modal with info message
|
||||
this.showProviderModal = true;
|
||||
@@ -242,6 +254,12 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
case NotificationProviderType.Telegram:
|
||||
this.showTelegramModal = true;
|
||||
break;
|
||||
case NotificationProviderType.Discord:
|
||||
this.showDiscordModal = true;
|
||||
break;
|
||||
case NotificationProviderType.Gotify:
|
||||
this.showGotifyModal = true;
|
||||
break;
|
||||
default:
|
||||
// For unsupported types, show the legacy modal with info message
|
||||
this.showProviderModal = true;
|
||||
@@ -327,6 +345,22 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
sendSilently: telegramConfig.sendSilently || false,
|
||||
};
|
||||
break;
|
||||
case NotificationProviderType.Discord:
|
||||
const discordConfig = provider.configuration as any;
|
||||
testRequest = {
|
||||
webhookUrl: discordConfig.webhookUrl,
|
||||
username: discordConfig.username || "",
|
||||
avatarUrl: discordConfig.avatarUrl || "",
|
||||
};
|
||||
break;
|
||||
case NotificationProviderType.Gotify:
|
||||
const gotifyConfig = provider.configuration as any;
|
||||
testRequest = {
|
||||
serverUrl: gotifyConfig.serverUrl,
|
||||
applicationToken: gotifyConfig.applicationToken,
|
||||
priority: gotifyConfig.priority ?? 5,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
this.notificationService.showError("Testing not supported for this provider type");
|
||||
return;
|
||||
@@ -369,6 +403,10 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
return "Pushover";
|
||||
case NotificationProviderType.Telegram:
|
||||
return "Telegram";
|
||||
case NotificationProviderType.Discord:
|
||||
return "Discord";
|
||||
case NotificationProviderType.Gotify:
|
||||
return "Gotify";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
@@ -537,6 +575,60 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Discord provider save
|
||||
*/
|
||||
onDiscordSave(data: DiscordFormData): void {
|
||||
if (this.modalMode === "edit" && this.editingProvider) {
|
||||
this.updateDiscordProvider(data);
|
||||
} else {
|
||||
this.createDiscordProvider(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Discord provider test
|
||||
*/
|
||||
onDiscordTest(data: DiscordFormData): void {
|
||||
const testRequest = {
|
||||
webhookUrl: data.webhookUrl,
|
||||
username: data.username,
|
||||
avatarUrl: data.avatarUrl,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.testProvider({
|
||||
testRequest,
|
||||
type: NotificationProviderType.Discord,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Gotify provider save
|
||||
*/
|
||||
onGotifySave(data: GotifyFormData): void {
|
||||
if (this.modalMode === "edit" && this.editingProvider) {
|
||||
this.updateGotifyProvider(data);
|
||||
} else {
|
||||
this.createGotifyProvider(data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Gotify provider test
|
||||
*/
|
||||
onGotifyTest(data: GotifyFormData): void {
|
||||
const testRequest = {
|
||||
serverUrl: data.serverUrl,
|
||||
applicationToken: data.applicationToken,
|
||||
priority: data.priority,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.testProvider({
|
||||
testRequest,
|
||||
type: NotificationProviderType.Gotify,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle provider modal cancel
|
||||
*/
|
||||
@@ -554,6 +646,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.showNtfyModal = false;
|
||||
this.showPushoverModal = false;
|
||||
this.showTelegramModal = false;
|
||||
this.showDiscordModal = false;
|
||||
this.showGotifyModal = false;
|
||||
this.showProviderModal = false;
|
||||
this.editingProvider = null;
|
||||
this.notificationProviderStore.clearTestResult();
|
||||
@@ -848,6 +942,112 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.monitorProviderOperation("updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new Discord provider
|
||||
*/
|
||||
private createDiscordProvider(data: DiscordFormData): void {
|
||||
const createDto = {
|
||||
name: data.name,
|
||||
isEnabled: data.enabled,
|
||||
onFailedImportStrike: data.onFailedImportStrike,
|
||||
onStalledStrike: data.onStalledStrike,
|
||||
onSlowStrike: data.onSlowStrike,
|
||||
onQueueItemDeleted: data.onQueueItemDeleted,
|
||||
onDownloadCleaned: data.onDownloadCleaned,
|
||||
onCategoryChanged: data.onCategoryChanged,
|
||||
webhookUrl: data.webhookUrl,
|
||||
username: data.username,
|
||||
avatarUrl: data.avatarUrl,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.createProvider({
|
||||
provider: createDto,
|
||||
type: NotificationProviderType.Discord,
|
||||
});
|
||||
this.monitorProviderOperation("created");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing Discord provider
|
||||
*/
|
||||
private updateDiscordProvider(data: DiscordFormData): void {
|
||||
if (!this.editingProvider) return;
|
||||
|
||||
const updateDto = {
|
||||
name: data.name,
|
||||
isEnabled: data.enabled,
|
||||
onFailedImportStrike: data.onFailedImportStrike,
|
||||
onStalledStrike: data.onStalledStrike,
|
||||
onSlowStrike: data.onSlowStrike,
|
||||
onQueueItemDeleted: data.onQueueItemDeleted,
|
||||
onDownloadCleaned: data.onDownloadCleaned,
|
||||
onCategoryChanged: data.onCategoryChanged,
|
||||
webhookUrl: data.webhookUrl,
|
||||
username: data.username,
|
||||
avatarUrl: data.avatarUrl,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.updateProvider({
|
||||
id: this.editingProvider.id,
|
||||
provider: updateDto,
|
||||
type: NotificationProviderType.Discord,
|
||||
});
|
||||
this.monitorProviderOperation("updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new Gotify provider
|
||||
*/
|
||||
private createGotifyProvider(data: GotifyFormData): void {
|
||||
const createDto = {
|
||||
name: data.name,
|
||||
isEnabled: data.enabled,
|
||||
onFailedImportStrike: data.onFailedImportStrike,
|
||||
onStalledStrike: data.onStalledStrike,
|
||||
onSlowStrike: data.onSlowStrike,
|
||||
onQueueItemDeleted: data.onQueueItemDeleted,
|
||||
onDownloadCleaned: data.onDownloadCleaned,
|
||||
onCategoryChanged: data.onCategoryChanged,
|
||||
serverUrl: data.serverUrl,
|
||||
applicationToken: data.applicationToken,
|
||||
priority: data.priority,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.createProvider({
|
||||
provider: createDto,
|
||||
type: NotificationProviderType.Gotify,
|
||||
});
|
||||
this.monitorProviderOperation("created");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing Gotify provider
|
||||
*/
|
||||
private updateGotifyProvider(data: GotifyFormData): void {
|
||||
if (!this.editingProvider) return;
|
||||
|
||||
const updateDto = {
|
||||
name: data.name,
|
||||
isEnabled: data.enabled,
|
||||
onFailedImportStrike: data.onFailedImportStrike,
|
||||
onStalledStrike: data.onStalledStrike,
|
||||
onSlowStrike: data.onSlowStrike,
|
||||
onQueueItemDeleted: data.onQueueItemDeleted,
|
||||
onDownloadCleaned: data.onDownloadCleaned,
|
||||
onCategoryChanged: data.onCategoryChanged,
|
||||
serverUrl: data.serverUrl,
|
||||
applicationToken: data.applicationToken,
|
||||
priority: data.priority,
|
||||
};
|
||||
|
||||
this.notificationProviderStore.updateProvider({
|
||||
id: this.editingProvider.id,
|
||||
provider: updateDto,
|
||||
type: NotificationProviderType.Gotify,
|
||||
});
|
||||
this.monitorProviderOperation("updated");
|
||||
}
|
||||
|
||||
/**
|
||||
* Monitor provider operation completion and close modals
|
||||
*/
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
optionValue="value"
|
||||
placeholder="Select version"
|
||||
styleClass="w-full"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
optionValue="value"
|
||||
placeholder="Select version"
|
||||
styleClass="w-full"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
optionValue="value"
|
||||
placeholder="Select version"
|
||||
styleClass="w-full"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
|
||||
</div>
|
||||
|
||||
@@ -234,6 +234,7 @@
|
||||
optionValue="value"
|
||||
placeholder="Select version"
|
||||
styleClass="w-full"
|
||||
appendTo="body"
|
||||
></p-select>
|
||||
<small *ngIf="hasError(instanceForm, 'version', 'required')" class="form-error-text">Version is required</small>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@ export class WhisparrSettingsComponent implements OnDestroy, CanComponentDeactiv
|
||||
globalForm: FormGroup;
|
||||
instanceForm: FormGroup;
|
||||
|
||||
// Version options for Whisparr (v2 and v3)
|
||||
versionOptions = [
|
||||
{ label: 'v2', value: 2 },
|
||||
{ label: 'v3', value: 3 }
|
||||
|
||||
@@ -16,6 +16,8 @@ export enum NotificationProviderType {
|
||||
Ntfy = "Ntfy",
|
||||
Pushover = "Pushover",
|
||||
Telegram = "Telegram",
|
||||
Discord = "Discord",
|
||||
Gotify = "Gotify",
|
||||
}
|
||||
|
||||
export enum AppriseMode {
|
||||
|
||||
72
docs/docs/configuration/notifications/discord.mdx
Normal file
72
docs/docs/configuration/notifications/discord.mdx
Normal file
@@ -0,0 +1,72 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
import {
|
||||
ConfigSection,
|
||||
ElementNavigator,
|
||||
SectionTitle,
|
||||
styles
|
||||
} from '@site/src/components/documentation';
|
||||
|
||||
# Discord
|
||||
|
||||
Discord is a popular communication platform that supports webhooks for automated messaging. Cleanuparr can send notifications directly to any Discord channel via webhooks.
|
||||
|
||||
<ElementNavigator />
|
||||
|
||||
<div className={styles.documentationPage}>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle icon="💬">Configuration</SectionTitle>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
Configure a Discord webhook to receive notifications in your Discord server.
|
||||
</p>
|
||||
|
||||
<ConfigSection
|
||||
title="Webhook URL"
|
||||
icon="🔗"
|
||||
id="webhook-url"
|
||||
>
|
||||
|
||||
The Discord webhook URL for sending messages. To create a webhook:
|
||||
|
||||
1. Open your Discord server and go to **Server Settings** → **Integrations** → **Webhooks**
|
||||
2. Click **New Webhook** and select the channel where notifications should appear
|
||||
3. Copy the **Webhook URL** and paste it here
|
||||
|
||||
Webhook URLs follow the format: `https://discord.com/api/webhooks/{id}/{token}`
|
||||
|
||||
Reference: https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Username"
|
||||
icon="👤"
|
||||
id="username"
|
||||
>
|
||||
|
||||
Override the default webhook username displayed in Discord. If left empty, the webhook's default name (configured in Discord) will be used.
|
||||
|
||||
Maximum length: 80 characters.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Avatar URL"
|
||||
icon="🖼️"
|
||||
id="avatar-url"
|
||||
>
|
||||
|
||||
Override the default webhook avatar with a custom image URL. If left empty, the webhook's default avatar (configured in Discord) will be used.
|
||||
|
||||
Must be a valid URL pointing to an image file.
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
82
docs/docs/configuration/notifications/gotify.mdx
Normal file
82
docs/docs/configuration/notifications/gotify.mdx
Normal file
@@ -0,0 +1,82 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
import {
|
||||
ConfigSection,
|
||||
ElementNavigator,
|
||||
SectionTitle,
|
||||
styles
|
||||
} from '@site/src/components/documentation';
|
||||
|
||||
# Gotify
|
||||
|
||||
Gotify is a simple, self-hosted push notification server. It provides a REST API for sending messages and has clients available for Android, web browsers, and CLI.
|
||||
|
||||
<ElementNavigator />
|
||||
|
||||
<div className={styles.documentationPage}>
|
||||
|
||||
<div className={styles.section}>
|
||||
|
||||
<SectionTitle icon="🔔">Configuration</SectionTitle>
|
||||
|
||||
<p className={styles.sectionDescription}>
|
||||
Configure your Gotify server connection to receive push notifications.
|
||||
</p>
|
||||
|
||||
<ConfigSection
|
||||
title="Server URL"
|
||||
icon="🌐"
|
||||
id="server-url"
|
||||
>
|
||||
|
||||
The URL of your Gotify server instance. This should be the base URL where your Gotify server is accessible.
|
||||
|
||||
Examples:
|
||||
- `https://gotify.example.com`
|
||||
- `http://192.168.1.100:8080`
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Application Token"
|
||||
icon="🔑"
|
||||
id="application-token"
|
||||
>
|
||||
|
||||
The application token used to authenticate with your Gotify server. To create an application token:
|
||||
|
||||
1. Log in to your Gotify web interface
|
||||
2. Go to **Apps** and click **Create Application**
|
||||
3. Give your application a name (e.g., "Cleanuparr")
|
||||
4. Copy the generated token and paste it here
|
||||
|
||||
Each application in Gotify has its own token and can be configured with a custom name and icon.
|
||||
|
||||
Reference: https://gotify.net/docs/
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
<ConfigSection
|
||||
title="Priority"
|
||||
icon="🔥"
|
||||
id="priority"
|
||||
>
|
||||
|
||||
The priority level for notifications (0-10). Higher priority messages may trigger different notification behaviors on Gotify clients:
|
||||
|
||||
- **0**: Minimum priority - may not trigger any notification
|
||||
- **1-3**: Low priority - quiet notification
|
||||
- **4-7**: Normal priority - standard notification behavior
|
||||
- **8-10**: High priority - may trigger more intrusive notifications
|
||||
|
||||
The exact behavior depends on your Gotify client configuration. Default is 5.
|
||||
|
||||
Reference: https://gotify.net/docs/msgextras
|
||||
|
||||
</ConfigSection>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
Reference in New Issue
Block a user