diff --git a/src/NzbDrone.Core/Notifications/Signal/InvalidResponseException.cs b/src/NzbDrone.Core/Notifications/Signal/InvalidResponseException.cs new file mode 100644 index 000000000..c57026d8b --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/InvalidResponseException.cs @@ -0,0 +1,16 @@ +using System; + +namespace NzbDrone.Core.Notifications.Signal +{ + public class SignalInvalidResponseException : Exception + { + public SignalInvalidResponseException() + { + } + + public SignalInvalidResponseException(string message) + : base(message) + { + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Signal/Signal.cs b/src/NzbDrone.Core/Notifications/Signal/Signal.cs new file mode 100644 index 000000000..8e5c322db --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/Signal.cs @@ -0,0 +1,74 @@ +using System.Collections.Generic; +using FluentValidation.Results; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Movies; + +namespace NzbDrone.Core.Notifications.Signal +{ + public class Signal : NotificationBase + { + private readonly ISignalProxy _proxy; + + public Signal(ISignalProxy proxy) + { + _proxy = proxy; + } + + public override string Name => "Signal"; + public override string Link => "https://signal.org/"; + + public override void OnGrab(GrabMessage grabMessage) + { + _proxy.SendNotification(MOVIE_GRABBED_TITLE, grabMessage.Message, Settings); + } + + public override void OnDownload(DownloadMessage message) + { + _proxy.SendNotification(MOVIE_DOWNLOADED_TITLE, message.Message, Settings); + } + + public override void OnMovieAdded(Movie movie) + { + _proxy.SendNotification(MOVIE_ADDED_TITLE, $"{movie.Title} added to library", Settings); + } + + public override void OnMovieFileDelete(MovieFileDeleteMessage deleteMessage) + { + _proxy.SendNotification(MOVIE_FILE_DELETED_TITLE, deleteMessage.Message, Settings); + } + + public override void OnMovieDelete(MovieDeleteMessage deleteMessage) + { + _proxy.SendNotification(MOVIE_DELETED_TITLE, deleteMessage.Message, Settings); + } + + public override void OnHealthIssue(HealthCheck.HealthCheck healthCheck) + { + _proxy.SendNotification(HEALTH_ISSUE_TITLE, healthCheck.Message, Settings); + } + + public override void OnHealthRestored(HealthCheck.HealthCheck previousCheck) + { + _proxy.SendNotification(HEALTH_RESTORED_TITLE, $"The following issue is now resolved: {previousCheck.Message}", Settings); + } + + public override void OnApplicationUpdate(ApplicationUpdateMessage updateMessage) + { + _proxy.SendNotification(APPLICATION_UPDATE_TITLE, updateMessage.Message, Settings); + } + + public override void OnManualInteractionRequired(ManualInteractionRequiredMessage message) + { + _proxy.SendNotification(MANUAL_INTERACTION_REQUIRED_TITLE, message.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/Signal/SignalError.cs b/src/NzbDrone.Core/Notifications/Signal/SignalError.cs new file mode 100644 index 000000000..15d5f56c6 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/SignalError.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.Notifications.Signal +{ + public class SignalError + { + public string Error { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Signal/SignalPayload.cs b/src/NzbDrone.Core/Notifications/Signal/SignalPayload.cs new file mode 100644 index 000000000..d644ad6af --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/SignalPayload.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Notifications.Signal +{ + public class SignalPayload + { + public string Message { get; set; } + public string Number { get; set; } + public string[] Recipients { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Signal/SignalProxy.cs b/src/NzbDrone.Core/Notifications/Signal/SignalProxy.cs new file mode 100644 index 000000000..bd4d3608c --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/SignalProxy.cs @@ -0,0 +1,119 @@ +using System; +using System.Net; +using System.Text; +using FluentValidation.Results; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Notifications.Signal +{ + public interface ISignalProxy + { + void SendNotification(string title, string message, SignalSettings settings); + ValidationFailure Test(SignalSettings settings); + } + + public class SignalProxy : ISignalProxy + { + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public SignalProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public void SendNotification(string title, string message, SignalSettings settings) + { + var text = new StringBuilder(); + text.AppendLine(title); + text.AppendLine(message); + + var urlSignalAPI = HttpRequestBuilder.BuildBaseUrl( + settings.UseSsl, + settings.Host, + settings.Port, + "/v2/send"); + + var requestBuilder = new HttpRequestBuilder(urlSignalAPI).Post(); + + if (settings.AuthUsername.IsNotNullOrWhiteSpace() && settings.AuthPassword.IsNotNullOrWhiteSpace()) + { + requestBuilder.NetworkCredential = new BasicNetworkCredential(settings.AuthUsername, settings.AuthPassword); + } + + var request = requestBuilder.Build(); + + request.Headers.ContentType = "application/json"; + + var payload = new SignalPayload + { + Message = text.ToString(), + Number = settings.SenderNumber, + Recipients = new[] { settings.ReceiverId } + }; + request.SetContent(payload.ToJson()); + _httpClient.Post(request); + } + + public ValidationFailure Test(SignalSettings settings) + { + try + { + const string title = "Test Notification"; + const string body = "This is a test message from Radarr"; + + SendNotification(title, body, settings); + } + catch (WebException ex) + { + _logger.Error(ex, "Unable to send test message: {0}", ex.Message); + return new ValidationFailure("Host", $"Unable to send test message: {ex.Message}"); + } + catch (HttpException ex) + { + _logger.Error(ex, "Unable to send test message: {0}", ex.Message); + + if (ex.Response.StatusCode == HttpStatusCode.BadRequest) + { + if (ex.Response.Content.ContainsIgnoreCase("400 The plain HTTP request was sent to HTTPS port")) + { + return new ValidationFailure("UseSsl", "SSL seems to be required"); + } + + var error = Json.Deserialize(ex.Response.Content); + + var property = "Host"; + + if (error.Error.ContainsIgnoreCase("Invalid group id")) + { + property = "ReceiverId"; + } + else if (error.Error.ContainsIgnoreCase("Invalid account")) + { + property = "SenderNumber"; + } + + return new ValidationFailure(property, $"Unable to send test message: {error.Error}"); + } + + if (ex.Response.StatusCode == HttpStatusCode.Unauthorized) + { + return new ValidationFailure("AuthUsername", "Login/Password invalid"); + } + + return new ValidationFailure("Host", $"Unable to send test message: {ex.Message}"); + } + catch (Exception ex) + { + _logger.Error(ex, "Unable to send test message: {0}", ex.Message); + return new ValidationFailure("Host", $"Unable to send test message: {ex.Message}"); + } + + return null; + } + } +} diff --git a/src/NzbDrone.Core/Notifications/Signal/SignalSettings.cs b/src/NzbDrone.Core/Notifications/Signal/SignalSettings.cs new file mode 100644 index 000000000..8906a45e3 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Signal/SignalSettings.cs @@ -0,0 +1,49 @@ +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.ThingiProvider; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.Notifications.Signal +{ + public class SignalSettingsValidator : AbstractValidator + { + public SignalSettingsValidator() + { + RuleFor(c => c.Host).NotEmpty(); + RuleFor(c => c.Port).NotEmpty(); + RuleFor(c => c.SenderNumber).NotEmpty(); + RuleFor(c => c.ReceiverId).NotEmpty(); + } + } + + public class SignalSettings : IProviderConfig + { + private static readonly SignalSettingsValidator Validator = new (); + + [FieldDefinition(0, Label = "Host", Type = FieldType.Textbox, HelpText = "localhost")] + public string Host { get; set; } + + [FieldDefinition(1, Label = "Port", Type = FieldType.Textbox, HelpText = "8080")] + public int Port { get; set; } + + [FieldDefinition(2, Label = "Use SSL", Type = FieldType.Checkbox, HelpText = "Use a secure connection.")] + public bool UseSsl { get; set; } + + [FieldDefinition(3, Label = "Sender Number", Privacy = PrivacyLevel.ApiKey, HelpText = "Phone number of the sender register in signal-api")] + public string SenderNumber { get; set; } + + [FieldDefinition(4, Label = "Group ID / PhoneNumber", HelpText = "GroupID / PhoneNumber of the receiver")] + public string ReceiverId { get; set; } + + [FieldDefinition(5, Label = "Login", Privacy = PrivacyLevel.UserName, HelpText = "Username used to authenticate requests toward signal-api")] + public string AuthUsername { get; set; } + + [FieldDefinition(6, Label = "Password", Type = FieldType.Password, Privacy = PrivacyLevel.Password, HelpText = "Password used to authenticate requests toward signal-api")] + public string AuthPassword { get; set; } + + public NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +}