mirror of
https://github.com/Cleanuparr/Cleanuparr.git
synced 2026-01-14 08:47:57 -05:00
Compare commits
3 Commits
main
...
add_gotify
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a79e7778c | ||
|
|
27141e7b8c | ||
|
|
31d36c71bb |
@@ -5,6 +5,7 @@ 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;
|
||||
|
||||
@@ -20,6 +21,7 @@ public static class NotificationsDI
|
||||
.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 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 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 UpdateGotifyProviderRequest : UpdateNotificationProviderRequestBase
|
||||
{
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
|
||||
public string ApplicationToken { get; init; } = string.Empty;
|
||||
|
||||
public int Priority { get; init; } = 5;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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;
|
||||
@@ -52,6 +53,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -79,6 +81,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
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()
|
||||
}
|
||||
})
|
||||
@@ -698,6 +701,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
|
||||
if (existingProvider == null)
|
||||
@@ -931,6 +935,7 @@ public sealed class NotificationProvidersController : ControllerBase
|
||||
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()
|
||||
}
|
||||
};
|
||||
@@ -1321,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}" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,5 @@ public enum NotificationProviderType
|
||||
Pushover,
|
||||
Telegram,
|
||||
Discord,
|
||||
Gotify,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -358,13 +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)]
|
||||
[InlineData(NotificationProviderType.Discord)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetActiveProvidersAsync_MapsProviderTypeCorrectly(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -384,12 +380,7 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(NotificationProviderType.Notifiarr)]
|
||||
[InlineData(NotificationProviderType.Apprise)]
|
||||
[InlineData(NotificationProviderType.Ntfy)]
|
||||
[InlineData(NotificationProviderType.Pushover)]
|
||||
[InlineData(NotificationProviderType.Telegram)]
|
||||
[InlineData(NotificationProviderType.Discord)]
|
||||
[MemberData(nameof(NotificationProviderTypes))]
|
||||
public async Task GetProvidersForEventAsync_ReturnsProviderForAllTypes(NotificationProviderType providerType)
|
||||
{
|
||||
// Arrange
|
||||
@@ -423,6 +414,7 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
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))
|
||||
};
|
||||
}
|
||||
@@ -576,5 +568,36 @@ public class NotificationConfigurationServiceTests : IDisposable
|
||||
};
|
||||
}
|
||||
|
||||
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()
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Cleanuparr.Domain.Enums;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Apprise;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Discord;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Gotify;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Models;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Notifiarr;
|
||||
using Cleanuparr.Infrastructure.Features.Notifications.Ntfy;
|
||||
@@ -23,6 +24,7 @@ public class NotificationProviderFactoryTests
|
||||
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;
|
||||
|
||||
@@ -35,6 +37,7 @@ public class NotificationProviderFactoryTests
|
||||
_pushoverProxyMock = new Mock<IPushoverProxy>();
|
||||
_telegramProxyMock = new Mock<ITelegramProxy>();
|
||||
_discordProxyMock = new Mock<IDiscordProxy>();
|
||||
_gotifyProxyMock = new Mock<IGotifyProxy>();
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(_appriseProxyMock.Object);
|
||||
@@ -44,6 +47,7 @@ public class NotificationProviderFactoryTests
|
||||
services.AddSingleton(_pushoverProxyMock.Object);
|
||||
services.AddSingleton(_telegramProxyMock.Object);
|
||||
services.AddSingleton(_discordProxyMock.Object);
|
||||
services.AddSingleton(_gotifyProxyMock.Object);
|
||||
|
||||
_serviceProvider = services.BuildServiceProvider();
|
||||
_factory = new NotificationProviderFactory(_serviceProvider);
|
||||
@@ -227,6 +231,32 @@ public class NotificationProviderFactoryTests
|
||||
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.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);
|
||||
}
|
||||
@@ -89,6 +89,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
.Include(p => p.PushoverConfiguration)
|
||||
.Include(p => p.TelegramConfiguration)
|
||||
.Include(p => p.DiscordConfiguration)
|
||||
.Include(p => p.GotifyConfiguration)
|
||||
.AsNoTracking()
|
||||
.ToListAsync();
|
||||
|
||||
@@ -141,6 +142,7 @@ public sealed class NotificationConfigurationService : INotificationConfiguratio
|
||||
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")
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ 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;
|
||||
|
||||
@@ -31,6 +32,7 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
|
||||
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")
|
||||
};
|
||||
}
|
||||
@@ -83,4 +85,12 @@ public sealed class NotificationProviderFactory : INotificationProviderFactory
|
||||
|
||||
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,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
|
||||
}
|
||||
@@ -58,6 +58,8 @@ public class DataContext : DbContext
|
||||
|
||||
public DbSet<DiscordConfig> DiscordConfigs { get; set; }
|
||||
|
||||
public DbSet<GotifyConfig> GotifyConfigs { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncHistory> BlacklistSyncHistory { get; set; }
|
||||
|
||||
public DbSet<BlacklistSyncConfig> BlacklistSyncConfigs { get; set; }
|
||||
@@ -163,6 +165,11 @@ public class DataContext : DbContext
|
||||
.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();
|
||||
});
|
||||
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,6 +561,43 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
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")
|
||||
@@ -1094,6 +1131,18 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
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")
|
||||
@@ -1194,6 +1243,8 @@ namespace Cleanuparr.Persistence.Migrations.Data
|
||||
|
||||
b.Navigation("DiscordConfiguration");
|
||||
|
||||
b.Navigation("GotifyConfiguration");
|
||||
|
||||
b.Navigation("NotifiarrConfiguration");
|
||||
|
||||
b.Navigation("NtfyConfiguration");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public sealed record NotificationConfig
|
||||
|
||||
public DiscordConfig? DiscordConfiguration { get; init; }
|
||||
|
||||
public GotifyConfig? GotifyConfiguration { get; init; }
|
||||
|
||||
[NotMapped]
|
||||
public bool IsConfigured => Type switch
|
||||
{
|
||||
@@ -56,6 +58,7 @@ public sealed record NotificationConfig
|
||||
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}")
|
||||
};
|
||||
|
||||
|
||||
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 |
@@ -157,6 +157,11 @@ export class DocumentationService {
|
||||
'discord.username': 'username',
|
||||
'discord.avatarUrl': 'avatar-url'
|
||||
},
|
||||
'notifications/gotify': {
|
||||
'gotify.serverUrl': 'server-url',
|
||||
'gotify.applicationToken': 'application-token',
|
||||
'gotify.priority': 'priority'
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private applicationPathService: ApplicationPathService) {}
|
||||
|
||||
@@ -264,6 +264,40 @@ export interface TestDiscordProviderRequest {
|
||||
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'
|
||||
})
|
||||
@@ -328,6 +362,13 @@ export class NotificationProviderService {
|
||||
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
|
||||
*/
|
||||
@@ -370,6 +411,13 @@ export class NotificationProviderService {
|
||||
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
|
||||
*/
|
||||
@@ -419,6 +467,13 @@ export class NotificationProviderService {
|
||||
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
|
||||
*/
|
||||
@@ -436,6 +491,8 @@ export class NotificationProviderService {
|
||||
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}`);
|
||||
}
|
||||
@@ -458,6 +515,8 @@ export class NotificationProviderService {
|
||||
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}`);
|
||||
}
|
||||
@@ -480,6 +539,8 @@ export class NotificationProviderService {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,13 @@ export class ProviderTypeSelectionComponent {
|
||||
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) {
|
||||
|
||||
@@ -78,6 +78,12 @@ export interface DiscordFormData extends BaseProviderFormData {
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface GotifyFormData extends BaseProviderFormData {
|
||||
serverUrl: string;
|
||||
applicationToken: string;
|
||||
priority: number;
|
||||
}
|
||||
|
||||
// Events for modal communication
|
||||
export interface ProviderModalEvents {
|
||||
save: (data: any) => void;
|
||||
|
||||
@@ -219,6 +219,17 @@
|
||||
(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, DiscordFormData } 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
|
||||
@@ -19,6 +19,7 @@ import { NtfyProviderComponent } from "./modals/ntfy-provider/ntfy-provider.comp
|
||||
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";
|
||||
@@ -57,6 +58,7 @@ import { NotificationService } from "../../core/services/notification.service";
|
||||
PushoverProviderComponent,
|
||||
TelegramProviderComponent,
|
||||
DiscordProviderComponent,
|
||||
GotifyProviderComponent,
|
||||
],
|
||||
providers: [NotificationProviderConfigStore, ConfirmationService, MessageService],
|
||||
templateUrl: "./notification-settings.component.html",
|
||||
@@ -75,6 +77,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
showPushoverModal = false;
|
||||
showTelegramModal = false;
|
||||
showDiscordModal = false;
|
||||
showGotifyModal = false;
|
||||
modalMode: 'add' | 'edit' = 'add';
|
||||
editingProvider: NotificationProviderDto | null = null;
|
||||
|
||||
@@ -192,6 +195,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
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;
|
||||
@@ -251,6 +257,9 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
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;
|
||||
@@ -344,6 +353,14 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
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;
|
||||
@@ -388,6 +405,8 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
return "Telegram";
|
||||
case NotificationProviderType.Discord:
|
||||
return "Discord";
|
||||
case NotificationProviderType.Gotify:
|
||||
return "Gotify";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
@@ -583,6 +602,33 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -601,6 +647,7 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
this.showPushoverModal = false;
|
||||
this.showTelegramModal = false;
|
||||
this.showDiscordModal = false;
|
||||
this.showGotifyModal = false;
|
||||
this.showProviderModal = false;
|
||||
this.editingProvider = null;
|
||||
this.notificationProviderStore.clearTestResult();
|
||||
@@ -948,6 +995,59 @@ export class NotificationSettingsComponent implements OnDestroy, CanComponentDea
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -17,6 +17,7 @@ export enum NotificationProviderType {
|
||||
Pushover = "Pushover",
|
||||
Telegram = "Telegram",
|
||||
Discord = "Discord",
|
||||
Gotify = "Gotify",
|
||||
}
|
||||
|
||||
export enum AppriseMode {
|
||||
|
||||
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