From a89a1a55d9860de38d675a0b40c8b1f8d750f919 Mon Sep 17 00:00:00 2001 From: soup Date: Fri, 15 Dec 2023 23:13:44 +0100 Subject: [PATCH] feat(notifications): add LunaSea support (#1284) * feat(notifications): add lunasea * fix(web): truncate overflow in PasswordFieldWide * refactor(notifications): centralize msg building Left the building logic in discord.go and notifiarr.go as is because of their unique structure. * refactor: moved components and swapped to outline - Refactored the iconComponentMap to use a single iconStyle variable. * upped size from 4 to 5 * rename NotificationBuilder function --- internal/domain/notification.go | 1 + internal/notification/gotify.go | 67 +------------ internal/notification/lunasea.go | 100 +++++++++++++++++++ internal/notification/message_builder.go | 56 +++++++++++ internal/notification/pushover.go | 69 ++----------- internal/notification/service.go | 4 + internal/notification/telegram.go | 42 +------- web/src/components/inputs/input_wide.tsx | 2 +- web/src/domain/constants.ts | 4 + web/src/forms/settings/NotificationForms.tsx | 33 +++++- web/src/screens/settings/Notifications.tsx | 45 ++------- web/src/screens/settings/_components.tsx | 44 ++++++++ web/src/types/Notification.d.ts | 2 +- 13 files changed, 266 insertions(+), 203 deletions(-) create mode 100644 internal/notification/lunasea.go create mode 100644 internal/notification/message_builder.go diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 8455df6..bd40a63 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -80,6 +80,7 @@ const ( NotificationTypeSlack NotificationType = "SLACK" NotificationTypeTelegram NotificationType = "TELEGRAM" NotificationTypeGotify NotificationType = "GOTIFY" + NotificationTypeLunaSea NotificationType = "LUNASEA" ) type NotificationEvent string diff --git a/internal/notification/gotify.go b/internal/notification/gotify.go index a3c578d..ec8fb17 100644 --- a/internal/notification/gotify.go +++ b/internal/notification/gotify.go @@ -14,7 +14,6 @@ import ( "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" - "github.com/dustin/go-humanize" "github.com/rs/zerolog" ) @@ -26,26 +25,28 @@ type gotifyMessage struct { type gotifySender struct { log zerolog.Logger Settings domain.Notification + builder NotificationBuilderPlainText } func NewGotifySender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { return &gotifySender{ log: log.With().Str("sender", "gotify").Logger(), Settings: settings, + builder: NotificationBuilderPlainText{}, } } func (s *gotifySender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { m := gotifyMessage{ - Message: s.buildMessage(payload), - Title: s.buildTitle(event), + Message: s.builder.BuildBody(payload), + Title: s.builder.BuildTitle(event), } data := url.Values{} data.Set("message", m.Message) data.Set("title", m.Title) - url := fmt.Sprintf("%v/message?token=%v", s.Settings.Host, s.Settings.Token); + url := fmt.Sprintf("%v/message?token=%v", s.Settings.Host, s.Settings.Token) req, err := http.NewRequest(http.MethodPost, url, strings.NewReader(data.Encode())) if err != nil { s.log.Error().Err(err).Msgf("gotify client request error: %v", event) @@ -116,61 +117,3 @@ func (s *gotifySender) isEnabledEvent(event domain.NotificationEvent) bool { return false } - -func (s *gotifySender) buildMessage(payload domain.NotificationPayload) string { - msg := "" - - if payload.Subject != "" && payload.Message != "" { - msg += fmt.Sprintf("%v\n%v", payload.Subject, payload.Message) - } - if payload.ReleaseName != "" { - msg += fmt.Sprintf("\nNew release: %v", payload.ReleaseName) - } - if payload.Size > 0 { - msg += fmt.Sprintf("\nSize: %v", humanize.Bytes(payload.Size)) - } - if payload.Status != "" { - msg += fmt.Sprintf("\nStatus: %v", payload.Status.String()) - } - if payload.Indexer != "" { - msg += fmt.Sprintf("\nIndexer: %v", payload.Indexer) - } - if payload.Filter != "" { - msg += fmt.Sprintf("\nFilter: %v", payload.Filter) - } - if payload.Action != "" { - action := fmt.Sprintf("\nAction: %v Type: %v", payload.Action, payload.ActionType) - if payload.ActionClient != "" { - action += fmt.Sprintf(" Client: %v", payload.ActionClient) - } - msg += action - } - if len(payload.Rejections) > 0 { - msg += fmt.Sprintf("\nRejections: %v", strings.Join(payload.Rejections, ", ")) - } - - return msg -} - -func (s *gotifySender) buildTitle(event domain.NotificationEvent) string { - title := "" - - switch event { - case domain.NotificationEventAppUpdateAvailable: - title = "Autobrr update available" - case domain.NotificationEventPushApproved: - title = "Push Approved" - case domain.NotificationEventPushRejected: - title = "Push Rejected" - case domain.NotificationEventPushError: - title = "Error" - case domain.NotificationEventIRCDisconnected: - title = "IRC Disconnected" - case domain.NotificationEventIRCReconnected: - title = "IRC Reconnected" - case domain.NotificationEventTest: - title = "Test" - } - - return title -} diff --git a/internal/notification/lunasea.go b/internal/notification/lunasea.go new file mode 100644 index 0000000..c72b3e9 --- /dev/null +++ b/internal/notification/lunasea.go @@ -0,0 +1,100 @@ +package notification + +import ( + "bytes" + "encoding/json" + "net/http" + "regexp" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/errors" + + "github.com/rs/zerolog" +) + +// unsure if this is the best approach to send an image with the notification +const defaultImageURL = "https://raw.githubusercontent.com/autobrr/autobrr/master/.github/images/logo.png" + +type LunaSeaMessage struct { + Title string `json:"title"` + Body string `json:"body"` + Image string `json:"image,omitempty"` +} + +type lunaSeaSender struct { + log zerolog.Logger + Settings domain.Notification + builder NotificationBuilderPlainText +} + +func (s *lunaSeaSender) rewriteWebhookURL(url string) string { + re := regexp.MustCompile(`/(radarr|sonarr|lidarr|tautulli|overseerr)/`) + return re.ReplaceAllString(url, "/custom/") +} // `custom` is not mentioned in their docs, so I thought this would be a good idea to add to avoid user errors + +func NewLunaSeaSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { + return &lunaSeaSender{ + log: log.With().Str("sender", "lunasea").Logger(), + Settings: settings, + builder: NotificationBuilderPlainText{}, + } +} + +func (s *lunaSeaSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + m := LunaSeaMessage{ + Title: s.builder.BuildTitle(event), + Body: s.builder.BuildBody(payload), + Image: defaultImageURL, + } + + jsonData, err := json.Marshal(m) + if err != nil { + s.log.Error().Err(err).Msg("lunasea client could not marshal data") + return errors.Wrap(err, "could not marshal data") + } + + rewrittenURL := s.rewriteWebhookURL(s.Settings.Webhook) + + req, err := http.NewRequest(http.MethodPost, rewrittenURL, bytes.NewBuffer(jsonData)) + if err != nil { + s.log.Error().Err(err).Msg("lunasea client request error") + return errors.Wrap(err, "could not create request") + } + + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 30 * time.Second} + res, err := client.Do(req) + if err != nil { + s.log.Error().Err(err).Msg("lunasea client request error") + return errors.Wrap(err, "could not make request") + } + + defer res.Body.Close() + + if res.StatusCode >= 300 { + s.log.Error().Msgf("bad status from lunasea: %v", res.StatusCode) + return errors.New("bad status: %v", res.StatusCode) + } + + s.log.Debug().Msg("notification successfully sent to lunasea") + + return nil +} + +func (s *lunaSeaSender) CanSend(event domain.NotificationEvent) bool { + if s.Settings.Enabled && s.Settings.Webhook != "" && s.isEnabledEvent(event) { + return true + } + return false +} + +func (s *lunaSeaSender) isEnabledEvent(event domain.NotificationEvent) bool { + for _, e := range s.Settings.Events { + if e == string(event) { + return true + } + } + return false +} diff --git a/internal/notification/message_builder.go b/internal/notification/message_builder.go new file mode 100644 index 0000000..16108c4 --- /dev/null +++ b/internal/notification/message_builder.go @@ -0,0 +1,56 @@ +package notification + +import ( + "fmt" + "strings" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/dustin/go-humanize" +) + +type NotificationBuilderPlainText struct{} + +// BuildBody constructs the body of the notification message. +func (b *NotificationBuilderPlainText) BuildBody(payload domain.NotificationPayload) string { + var parts []string + + buildPart := func(condition bool, format string, a ...interface{}) { + if condition { + parts = append(parts, fmt.Sprintf(format, a...)) + } + } + + buildPart(payload.Subject != "" && payload.Message != "", "%v\n%v", payload.Subject, payload.Message) + buildPart(payload.ReleaseName != "", "\nNew release: %v", payload.ReleaseName) + buildPart(payload.Size > 0, "\nSize: %v", humanize.Bytes(payload.Size)) + buildPart(payload.Status != "", "\nStatus: %v", payload.Status.String()) + buildPart(payload.Indexer != "", "\nIndexer: %v", payload.Indexer) + buildPart(payload.Filter != "", "\nFilter: %v", payload.Filter) + buildPart(payload.Action != "", "\nAction: %v Type: %v", payload.Action, payload.ActionType) + buildPart(len(payload.Rejections) > 0, "\nRejections: %v", strings.Join(payload.Rejections, ", ")) + + if payload.Action != "" && payload.ActionClient != "" { + parts = append(parts, fmt.Sprintf(" Client: %v", payload.ActionClient)) + } + + return strings.Join(parts, "\n") +} + +// BuildTitle constructs the title of the notification message. +func (b *NotificationBuilderPlainText) BuildTitle(event domain.NotificationEvent) string { + titles := map[domain.NotificationEvent]string{ + domain.NotificationEventAppUpdateAvailable: "Autobrr update available", + domain.NotificationEventPushApproved: "Push Approved", + domain.NotificationEventPushRejected: "Push Rejected", + domain.NotificationEventPushError: "Error", + domain.NotificationEventIRCDisconnected: "IRC Disconnected", + domain.NotificationEventIRCReconnected: "IRC Reconnected", + domain.NotificationEventTest: "Test", + } + + if title, ok := titles[event]; ok { + return title + } + + return "New Event" +} diff --git a/internal/notification/pushover.go b/internal/notification/pushover.go index b523b49..f0186db 100644 --- a/internal/notification/pushover.go +++ b/internal/notification/pushover.go @@ -5,7 +5,6 @@ package notification import ( "fmt" - "html" "io" "net/http" "net/url" @@ -16,7 +15,6 @@ import ( "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" - "github.com/dustin/go-humanize" "github.com/rs/zerolog" ) @@ -34,6 +32,7 @@ type pushoverSender struct { log zerolog.Logger Settings domain.Notification baseUrl string + builder NotificationBuilderPlainText } func NewPushoverSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { @@ -45,12 +44,16 @@ func NewPushoverSender(log zerolog.Logger, settings domain.Notification) domain. } func (s *pushoverSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + + title := s.builder.BuildTitle(event) + message := s.builder.BuildBody(payload) + m := pushoverMessage{ Token: s.Settings.APIKey, User: s.Settings.Token, Priority: s.Settings.Priority, - Message: s.buildMessage(payload), - Title: s.buildTitle(event), + Message: message, + Title: title, Timestamp: time.Now(), Html: 1, } @@ -139,61 +142,3 @@ func (s *pushoverSender) isEnabledEvent(event domain.NotificationEvent) bool { return false } - -func (s *pushoverSender) buildMessage(payload domain.NotificationPayload) string { - msg := "" - - if payload.Subject != "" && payload.Message != "" { - msg += fmt.Sprintf("%v\n%v", payload.Subject, html.EscapeString(payload.Message)) - } - if payload.ReleaseName != "" { - msg += fmt.Sprintf("\nNew release: %v", html.EscapeString(payload.ReleaseName)) - } - if payload.Size > 0 { - msg += fmt.Sprintf("\nSize: %v", humanize.Bytes(payload.Size)) - } - if payload.Status != "" { - msg += fmt.Sprintf("\nStatus: %v", payload.Status.String()) - } - if payload.Indexer != "" { - msg += fmt.Sprintf("\nIndexer: %v", payload.Indexer) - } - if payload.Filter != "" { - msg += fmt.Sprintf("\nFilter: %v", html.EscapeString(payload.Filter)) - } - if payload.Action != "" { - action := fmt.Sprintf("\nAction: %v Type: %v", html.EscapeString(payload.Action), payload.ActionType) - if payload.ActionClient != "" { - action += fmt.Sprintf(" Client: %v", html.EscapeString(payload.ActionClient)) - } - msg += action - } - if len(payload.Rejections) > 0 { - msg += fmt.Sprintf("\nRejections: %v", strings.Join(payload.Rejections, ", ")) - } - - return msg -} - -func (s *pushoverSender) buildTitle(event domain.NotificationEvent) string { - title := "" - - switch event { - case domain.NotificationEventAppUpdateAvailable: - title = "Autobrr update available" - case domain.NotificationEventPushApproved: - title = "Push Approved" - case domain.NotificationEventPushRejected: - title = "Push Rejected" - case domain.NotificationEventPushError: - title = "Error" - case domain.NotificationEventIRCDisconnected: - title = "IRC Disconnected" - case domain.NotificationEventIRCReconnected: - title = "IRC Reconnected" - case domain.NotificationEventTest: - title = "Test" - } - - return title -} diff --git a/internal/notification/service.go b/internal/notification/service.go index b98bedb..acd2331 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -132,6 +132,8 @@ func (s *service) registerSenders() { s.senders = append(s.senders, NewPushoverSender(s.log, n)) case domain.NotificationTypeGotify: s.senders = append(s.senders, NewGotifySender(s.log, n)) + case domain.NotificationTypeLunaSea: + s.senders = append(s.senders, NewLunaSeaSender(s.log, n)) } } } @@ -247,6 +249,8 @@ func (s *service) Test(ctx context.Context, notification domain.Notification) er agent = NewPushoverSender(s.log, notification) case domain.NotificationTypeGotify: agent = NewGotifySender(s.log, notification) + case domain.NotificationTypeLunaSea: + agent = NewLunaSeaSender(s.log, notification) default: s.log.Error().Msgf("unsupported notification type: %v", notification.Type) return errors.New("unsupported notification type") diff --git a/internal/notification/telegram.go b/internal/notification/telegram.go index e71788c..0459ac2 100644 --- a/internal/notification/telegram.go +++ b/internal/notification/telegram.go @@ -7,17 +7,14 @@ import ( "bytes" "encoding/json" "fmt" - "html" "io" "net/http" "strconv" - "strings" "time" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/pkg/errors" - "github.com/dustin/go-humanize" "github.com/rs/zerolog" ) @@ -33,6 +30,7 @@ type telegramSender struct { log zerolog.Logger Settings domain.Notification ThreadID int + builder NotificationBuilderPlainText } func NewTelegramSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { @@ -52,9 +50,10 @@ func NewTelegramSender(log zerolog.Logger, settings domain.Notification) domain. } func (s *telegramSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + message := s.builder.BuildBody(payload) m := TelegramMessage{ ChatID: s.Settings.Channel, - Text: s.buildMessage(event, payload), + Text: message, MessageThreadID: s.ThreadID, ParseMode: "HTML", //ParseMode: "MarkdownV2", @@ -126,38 +125,3 @@ func (s *telegramSender) isEnabledEvent(event domain.NotificationEvent) bool { return false } - -func (s *telegramSender) buildMessage(event domain.NotificationEvent, payload domain.NotificationPayload) string { - msg := "" - - if payload.Subject != "" && payload.Message != "" { - msg += fmt.Sprintf("%v\n%v", payload.Subject, html.EscapeString(payload.Message)) - } - if payload.ReleaseName != "" { - msg += fmt.Sprintf("\nNew release: %v", html.EscapeString(payload.ReleaseName)) - } - if payload.Size > 0 { - msg += fmt.Sprintf("\nFile Size: %v", html.EscapeString(humanize.Bytes(payload.Size))) - } - if payload.Status != "" { - msg += fmt.Sprintf("\nStatus: %v", payload.Status.String()) - } - if payload.Indexer != "" { - msg += fmt.Sprintf("\nIndexer: %v", payload.Indexer) - } - if payload.Filter != "" { - msg += fmt.Sprintf("\nFilter: %v", html.EscapeString(payload.Filter)) - } - if payload.Action != "" { - action := fmt.Sprintf("\nAction: %v Type: %v", html.EscapeString(payload.Action), payload.ActionType) - if payload.ActionClient != "" { - action += fmt.Sprintf(" Client: %v", html.EscapeString(payload.ActionClient)) - } - msg += action - } - if len(payload.Rejections) > 0 { - msg += fmt.Sprintf("\nRejections: %v", strings.Join(payload.Rejections, ", ")) - } - - return msg -} diff --git a/web/src/components/inputs/input_wide.tsx b/web/src/components/inputs/input_wide.tsx index 5aa4d86..abf8505 100644 --- a/web/src/components/inputs/input_wide.tsx +++ b/web/src/components/inputs/input_wide.tsx @@ -147,7 +147,7 @@ export const PasswordFieldWide = ({ meta.touched && meta.error ? "border-red-500 focus:ring-red-500 focus:border-red-500" : "border-gray-300 dark:border-gray-700 focus:ring-blue-500 dark:focus:ring-blue-500 focus:border-blue-500 dark:focus:border-blue-500", - "block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100" + "block w-full shadow-sm sm:text-sm rounded-md border py-2.5 bg-gray-100 dark:bg-gray-850 dark:text-gray-100 overflow-hidden pr-8" )} placeholder={placeholder} required={required} diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index a76d4dc..3ef8d9f 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -399,6 +399,10 @@ export const NotificationTypeOptions: OptionBasicTyped[] = [ { label: "Gotify", value: "GOTIFY" + }, + { + label: "LunaSea", + value: "LUNASEA" } ]; diff --git a/web/src/forms/settings/NotificationForms.tsx b/web/src/forms/settings/NotificationForms.tsx index a39c065..cf2860e 100644 --- a/web/src/forms/settings/NotificationForms.tsx +++ b/web/src/forms/settings/NotificationForms.tsx @@ -70,6 +70,36 @@ function FormFieldsNotifiarr() { ); } +function FormFieldsLunaSea() { + return ( +
+
+ Settings +

+ LunaSea offers notifications across all devices linked to your account (User-Based) or to a single device without an account, using a unique webhook per device (Device-Based). +

+

+ {"Read the "} + + LunaSea docs + + {"."} +

+
+ + +
+ ); +} + function FormFieldsTelegram() { return (
@@ -172,7 +202,8 @@ const componentMap: componentMapType = { NOTIFIARR: , TELEGRAM: , PUSHOVER: , - GOTIFY: + GOTIFY: , + LUNASEA: }; interface NotificationAddFormValues { diff --git a/web/src/screens/settings/Notifications.tsx b/web/src/screens/settings/Notifications.tsx index 3092b6c..41120cc 100644 --- a/web/src/screens/settings/Notifications.tsx +++ b/web/src/screens/settings/Notifications.tsx @@ -15,6 +15,7 @@ import toast from "react-hot-toast"; import { Section } from "./_components"; import { PlusIcon } from "@heroicons/react/24/solid"; import { Checkbox } from "@components/Checkbox"; +import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, PushoverIcon, TelegramIcon } from "./_components"; export const notificationKeys = { all: ["notifications"] as const, @@ -68,44 +69,14 @@ function NotificationSettings() { ); } - -const DiscordIcon = () => ( - - - -); - -const TelegramIcon = () => ( - - - -); - -const PushoverIcon = () => ( - - - -); - -const GotifyIcon = () => ( - - - -); - - +const iconStyle = "flex items-center px-2 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-400"; const iconComponentMap: componentMapType = { - DISCORD: Discord, - NOTIFIARR: Notifiarr, - TELEGRAM: Telegram, - PUSHOVER: Pushover, - GOTIFY: Gotify + DISCORD: Discord, + NOTIFIARR: Notifiarr, + TELEGRAM: Telegram, + PUSHOVER: Pushover, + GOTIFY: Gotify, + LUNASEA: LunaSea }; interface ListItemProps { diff --git a/web/src/screens/settings/_components.tsx b/web/src/screens/settings/_components.tsx index b649405..125b8f2 100644 --- a/web/src/screens/settings/_components.tsx +++ b/web/src/screens/settings/_components.tsx @@ -4,6 +4,7 @@ */ import { classNames } from "@utils"; +import { SVGProps } from "react"; type SectionProps = { title: string; @@ -82,3 +83,46 @@ export const RowItem = ({
); + +const commonSVGProps: SVGProps = { + clipRule: "evenodd", fill: "currentColor", fillRule: "evenodd", xmlns: "http://www.w3.org/2000/svg", + className: "mr-2 h-5" +}; + +export const DiscordIcon = () => ( + + + +); + +export const NotifiarrIcon = () => ( + + + + +); + +export const TelegramIcon = () => ( + + + +); + +export const PushoverIcon = () => ( + + + +); + +export const GotifyIcon = () => ( + + + +); + +export const LunaSeaIcon = () => ( + + + + +); diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index 3344798..0bf0469 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: GPL-2.0-or-later */ -type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM" | "PUSHOVER" | "GOTIFY"; +type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM" | "PUSHOVER" | "GOTIFY" | "LUNASEA"; type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED"