diff --git a/src/NzbDrone.Core/Notifications/Ntfy/Ntfy.cs b/src/NzbDrone.Core/Notifications/Ntfy/Ntfy.cs new file mode 100644 index 000000000..2ebd8ccd8 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Ntfy/Ntfy.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +using FluentValidation.Results; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.Notifications.Ntfy +{ + public class Ntfy : NotificationBase + { + private readonly INtfyProxy _proxy; + + public Ntfy(INtfyProxy proxy) + { + _proxy = proxy; + } + + public override string Name => "ntfy.sh"; + + public override string Link => "https://ntfy.sh/"; + + public override void OnGrab(GrabMessage grabMessage) + { + _proxy.SendNotification(ALBUM_GRABBED_TITLE_BRANDED, grabMessage.Message, Settings); + } + + public override void OnReleaseImport(AlbumDownloadMessage message) + { + _proxy.SendNotification(ALBUM_DOWNLOADED_TITLE_BRANDED, message.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE_BRANDED, healthCheck.Message, Settings); + } + + public override void OnDownloadFailure(DownloadFailedMessage message) + { + _proxy.SendNotification(DOWNLOAD_FAILURE_TITLE_BRANDED, message.Message, Settings); + } + + public override void OnImportFailure(AlbumDownloadMessage message) + { + _proxy.SendNotification(IMPORT_FAILURE_TITLE_BRANDED, message.Message, Settings); + } + + public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage) + { + _proxy.SendNotification(APPLICATION_UPDATE_TITLE_BRANDED, updateMessage.Message, Settings); + } + + public override ValidationResult Test() + { + var failures = new List(); + + failures.AddIfNotNull(_proxy.Test(Settings)); + + return new ValidationResult(failures); + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Ntfy/NtfyException.cs b/src/NzbDrone.Core/Notifications/Ntfy/NtfyException.cs new file mode 100644 index 000000000..a6e70080e --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Ntfy/NtfyException.cs @@ -0,0 +1,18 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.Notifications.Ntfy +{ + public class NtfyException : NzbDroneException + { + public NtfyException(string message) + : base(message) + { + } + + public NtfyException(string message, Exception innerException, params object[] args) + : base(message, innerException, args) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Ntfy/NtfyPriority.cs b/src/NzbDrone.Core/Notifications/Ntfy/NtfyPriority.cs new file mode 100644 index 000000000..48c00ac7d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Ntfy/NtfyPriority.cs @@ -0,0 +1,11 @@ +namespace NzbDrone.Core.Notifications.Ntfy +{ + public enum NtfyPriority + { + Min = 1, + Low = 2, + Default = 3, + High = 4, + Max = 5 + } +} diff --git a/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs b/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs new file mode 100644 index 000000000..d670cb724 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Ntfy/NtfyProxy.cs @@ -0,0 +1,138 @@ +using System; +using System.Linq; +using System.Net; + +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Notifications.Ntfy +{ + public interface INtfyProxy + { + void SendNotification(string title, string message, NtfySettings settings); + + ValidationFailure Test(NtfySettings settings); + } + + public class NtfyProxy : INtfyProxy + { + private const string DEFAULT_PUSH_URL = "https://ntfy.sh"; + + private readonly IHttpClient _httpClient; + + private readonly Logger _logger; + + public NtfyProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void SendNotification(string title, string message, NtfySettings settings) + { + var error = false; + + var serverUrl = settings.ServerUrl.IsNullOrWhiteSpace() ? NtfyProxy.DEFAULT_PUSH_URL : settings.ServerUrl; + + foreach (var topic in settings.Topics) + { + var request = BuildTopicRequest(serverUrl, topic); + + try + { + SendNotification(title, message, request, settings); + } + catch (NtfyException ex) + { + _logger.Error(ex, "Unable to send test message to {0}", topic); + error = true; + } + } + + if (error) + { + throw new NtfyException("Unable to send Ntfy notifications to all topics"); + } + } + + private HttpRequestBuilder BuildTopicRequest(string serverUrl, string topic) + { + var trimServerUrl = serverUrl.TrimEnd('/'); + + var requestBuilder = new HttpRequestBuilder($"{trimServerUrl}/{topic}").Post(); + + return requestBuilder; + } + + public ValidationFailure Test(NtfySettings settings) + { + try + { + const string title = "Lidarr - Test Notification"; + + const string body = "This is a test message from Lidarr"; + + SendNotification(title, body, settings); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized || ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Error(ex, "Authorization is required"); + return new ValidationFailure("UserName", "Authorization is required"); + } + + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("ServerUrl", "Unable to send test message"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message"); + return new ValidationFailure("", "Unable to send test message"); + } + + return null; + } + + private void SendNotification(string title, string message, HttpRequestBuilder requestBuilder, NtfySettings settings) + { + try + { + requestBuilder.Headers.Add("X-Title", title); + requestBuilder.Headers.Add("X-Message", message); + requestBuilder.Headers.Add("X-Priority", settings.Priority.ToString()); + + if (settings.Tags.Any()) + { + requestBuilder.Headers.Add("X-Tags", settings.Tags.Join(",")); + } + + if (!settings.ClickUrl.IsNullOrWhiteSpace()) + { + requestBuilder.Headers.Add("X-Click", settings.ClickUrl); + } + + var request = requestBuilder.Build(); + + if (!settings.UserName.IsNullOrWhiteSpace() && !settings.Password.IsNullOrWhiteSpace()) + { + request.AddBasicAuthentication(settings.UserName, settings.Password); + } + + _httpClient.Execute(request); + } + catch (HttpException ex) + { + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized || ex.Response.StatusCode == HttpStatusCode.Forbidden) + { + _logger.Error(ex, "Authorization is required"); + throw; + } + + throw new NtfyException("Unable to send text message: {0}", ex, ex.Message); + } + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Ntfy/NtfySettings.cs b/src/NzbDrone.Core/Notifications/Ntfy/NtfySettings.cs new file mode 100644 index 000000000..94190458d --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Ntfy/NtfySettings.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Ntfy +{ + public class NtfySettingsValidator : AbstractValidator + { + public NtfySettingsValidator() + { + RuleFor(c => c.Topics).NotEmpty(); + RuleFor(c => c.Priority).InclusiveBetween(1, 5); + RuleFor(c => c.ServerUrl).IsValidUrl().When(c => !c.ServerUrl.IsNullOrWhiteSpace()); + RuleFor(c => c.ClickUrl).IsValidUrl().When(c => !c.ClickUrl.IsNullOrWhiteSpace()); + RuleFor(c => c.UserName).NotEmpty().When(c => !c.Password.IsNullOrWhiteSpace()); + RuleFor(c => c.Password).NotEmpty().When(c => !c.UserName.IsNullOrWhiteSpace()); + RuleForEach(c => c.Topics).NotEmpty().Matches("[a-zA-Z0-9_-]+").Must(c => !InvalidTopics.Contains(c)).WithMessage("Invalid topic"); + } + + private static List InvalidTopics => new List { "announcements", "app", "docs", "settings", "stats", "mytopic-rw", "mytopic-ro", "mytopic-wo" }; + } + + public class NtfySettings : IProviderConfig + { + private static readonly NtfySettingsValidator Validator = new NtfySettingsValidator(); + + public NtfySettings() + { + Topics = Array.Empty(); + Priority = 3; + } + + [FieldDefinition(0, Label = "Server Url", Type = FieldType.Url, HelpLink = "https://ntfy.sh/docs/install/", HelpText = "Leave blank to use public server (https://ntfy.sh)")] + public string ServerUrl { get; set; } + + [FieldDefinition(1, Label = "User Name", HelpText = "Optional Authorization", Privacy = PrivacyLevel.UserName)] + public string UserName { get; set; } + + [FieldDefinition(2, Label = "Password", Type = FieldType.Password, HelpText = "Optional Password", Privacy = PrivacyLevel.Password)] + public string Password { get; set; } + + [FieldDefinition(3, Label = "Priority", Type = FieldType.Select, SelectOptions = typeof(NtfyPriority))] + public int Priority { get; set; } + + [FieldDefinition(4, Label = "Topics", HelpText = "List of Topics to send notifications to", Type = FieldType.Tag)] + public IEnumerable Topics { get; set; } + + [FieldDefinition(5, Label = "Ntfy Tags and Emojis", Type = FieldType.Tag, HelpText = "Optional list of tags or emojis to use", HelpLink = "https://ntfy.sh/docs/emojis/")] + public IEnumerable Tags { get; set; } + + [FieldDefinition(6, Label = "Click Url", Type = FieldType.Url, HelpText = "Optional link when user clicks notification")] + public string ClickUrl { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +}