From 63d4c21e54b758bf3a32d34b4ba8a2ecb08e6952 Mon Sep 17 00:00:00 2001 From: ze0s <43699394+zze0s@users.noreply.github.com> Date: Mon, 19 Sep 2022 15:44:31 +0200 Subject: [PATCH] feat(notifications): add Notifiarr support (#464) --- internal/database/notification.go | 23 ++- internal/domain/notification.go | 1 + internal/notification/notifiarr.go | 168 +++++++++++++++++++ internal/notification/service.go | 104 +++++++++++- web/src/domain/constants.ts | 11 +- web/src/forms/settings/NotificationForms.tsx | 33 +++- web/src/screens/settings/Notifications.tsx | 1 + web/src/types/Notification.d.ts | 3 +- 8 files changed, 326 insertions(+), 18 deletions(-) create mode 100644 internal/notification/notifiarr.go diff --git a/internal/database/notification.go b/internal/database/notification.go index 33240f1..69f1074 100644 --- a/internal/database/notification.go +++ b/internal/database/notification.go @@ -28,7 +28,7 @@ func NewNotificationRepo(log logger.Logger, db *DB) domain.NotificationRepo { func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQueryParams) ([]domain.Notification, int, error) { queryBuilder := r.db.squirrel. - Select("id", "name", "type", "enabled", "events", "webhook", "token", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). + Select("id", "name", "type", "enabled", "events", "webhook", "token", "api_key", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). From("notification"). OrderBy("name") @@ -49,15 +49,15 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ for rows.Next() { var n domain.Notification - var webhook, token, channel sql.NullString + var webhook, token, apiKey, channel sql.NullString //var token, apiKey, webhook, title, icon, host, username, password, channel, targets, devices sql.NullString //if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &token, &apiKey, &webhook, &title, &icon, &host, &username, &password, &channel, &targets, &devices, &n.CreatedAt, &n.UpdatedAt); err != nil { //var token, apiKey, webhook, title, icon, host, username, password, channel, targets, devices sql.NullString - if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &webhook, &token, &channel, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil { + if err := rows.Scan(&n.ID, &n.Name, &n.Type, &n.Enabled, pq.Array(&n.Events), &webhook, &token, &apiKey, &channel, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil { return nil, 0, errors.Wrap(err, "error scanning row") } - //n.APIKey = apiKey.String + n.APIKey = apiKey.String n.Webhook = webhook.String n.Token = token.String n.Channel = channel.String @@ -130,6 +130,16 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi "enabled", "events", "token", + "api_key", + "webhook", + "title", + "icon", + "host", + "username", + "password", + "channel", + "targets", + "devices", "created_at", "updated_at", ). @@ -172,6 +182,7 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notification) (*domain.Notification, error) { webhook := toNullString(notification.Webhook) token := toNullString(notification.Token) + apiKey := toNullString(notification.APIKey) channel := toNullString(notification.Channel) queryBuilder := r.db.squirrel. @@ -183,6 +194,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi "events", "webhook", "token", + "api_key", "channel", ). Values( @@ -192,6 +204,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi pq.Array(notification.Events), webhook, token, + apiKey, channel, ). Suffix("RETURNING id").RunWith(r.db.handler) @@ -213,6 +226,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notification) (*domain.Notification, error) { webhook := toNullString(notification.Webhook) token := toNullString(notification.Token) + apiKey := toNullString(notification.APIKey) channel := toNullString(notification.Channel) queryBuilder := r.db.squirrel. @@ -223,6 +237,7 @@ func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notif Set("events", pq.Array(notification.Events)). Set("webhook", webhook). Set("token", token). + Set("api_key", apiKey). Set("channel", channel). Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")). Where("id = ?", notification.ID) diff --git a/internal/domain/notification.go b/internal/domain/notification.go index 0cfcfbf..0b71a5c 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -64,6 +64,7 @@ type NotificationType string const ( NotificationTypeDiscord NotificationType = "DISCORD" + NotificationTypeNotifiarr NotificationType = "NOTIFIARR" NotificationTypeIFTTT NotificationType = "IFTTT" NotificationTypeJoin NotificationType = "JOIN" NotificationTypeMattermost NotificationType = "MATTERMOST" diff --git a/internal/notification/notifiarr.go b/internal/notification/notifiarr.go new file mode 100644 index 0000000..34d8517 --- /dev/null +++ b/internal/notification/notifiarr.go @@ -0,0 +1,168 @@ +package notification + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/errors" + + "github.com/rs/zerolog" +) + +type notifiarrMessage struct { + Event string `json:"event"` + Data notifiarrMessageData `json:"data"` +} + +type notifiarrMessageData struct { + Subject string `json:"subject"` + Message string `json:"message"` + Event domain.NotificationEvent `json:"event"` + ReleaseName *string `json:"release_name,omitempty"` + Filter *string `json:"filter,omitempty"` + Indexer *string `json:"indexer,omitempty"` + InfoHash *string `json:"info_hash,omitempty"` + Size *uint64 `json:"size,omitempty"` + Status *domain.ReleasePushStatus `json:"status,omitempty"` + Action *string `json:"action,omitempty"` + ActionType *domain.ActionType `json:"action_type,omitempty"` + ActionClient *string `json:"action_client,omitempty"` + Rejections []string `json:"rejections,omitempty"` + Protocol *domain.ReleaseProtocol `json:"protocol,omitempty"` // torrent + Implementation *domain.ReleaseImplementation `json:"implementation,omitempty"` // irc, rss, api + Timestamp time.Time `json:"timestamp"` +} + +type notifiarrSender struct { + log zerolog.Logger + Settings domain.Notification +} + +func NewNotifiarrSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { + return ¬ifiarrSender{ + log: log.With().Str("sender", "notifiarr").Logger(), + Settings: settings, + } +} + +func (s *notifiarrSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + m := notifiarrMessage{ + Event: string(event), + Data: s.buildMessage(payload), + } + + jsonData, err := json.Marshal(m) + if err != nil { + s.log.Error().Err(err).Msgf("notifiarr client could not marshal data: %v", m) + return errors.Wrap(err, "could not marshal data: %+v", m) + } + + url := fmt.Sprintf("https://notifiarr.com/api/v1/notification/autobrr/%v", s.Settings.APIKey) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event) + return errors.Wrap(err, "could not create request") + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "autobrr") + + t := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + + client := http.Client{Transport: t, Timeout: 30 * time.Second} + res, err := client.Do(req) + if err != nil { + s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event) + return errors.Wrap(err, "could not make request: %+v", req) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + s.log.Error().Err(err).Msgf("notifiarr client request error: %v", event) + return errors.Wrap(err, "could not read data") + } + + defer res.Body.Close() + + s.log.Trace().Msgf("notifiarr status: %v response: %v", res.StatusCode, string(body)) + + if res.StatusCode != http.StatusOK { + s.log.Error().Err(err).Msgf("notifiarr client request error: %v", string(body)) + return errors.New("bad status: %v body: %v", res.StatusCode, string(body)) + } + + s.log.Debug().Msg("notification successfully sent to notifiarr") + + return nil +} + +func (s *notifiarrSender) CanSend(event domain.NotificationEvent) bool { + if s.isEnabled() && s.isEnabledEvent(event) { + return true + } + return false +} + +func (s *notifiarrSender) isEnabled() bool { + if s.Settings.Enabled && s.Settings.Webhook != "" { + return true + } + return false +} + +func (s *notifiarrSender) isEnabledEvent(event domain.NotificationEvent) bool { + for _, e := range s.Settings.Events { + if e == string(event) { + return true + } + } + + return false +} + +func (s *notifiarrSender) buildMessage(payload domain.NotificationPayload) notifiarrMessageData { + m := notifiarrMessageData{ + Event: payload.Event, + Timestamp: payload.Timestamp, + } + + if payload.Subject != "" && payload.Message != "" { + m.Subject = payload.Subject + m.Message = payload.Message + } + if payload.ReleaseName != "" { + m.ReleaseName = &payload.ReleaseName + } + if payload.Status != "" { + m.Status = &payload.Status + } + if payload.Indexer != "" { + m.Indexer = &payload.Indexer + } + if payload.Filter != "" { + m.Filter = &payload.Filter + } + if payload.Action != "" || payload.ActionClient != "" { + m.Action = &payload.Action + + if payload.ActionClient != "" { + m.ActionClient = &payload.ActionClient + } + } + if len(payload.Rejections) > 0 { + m.Rejections = payload.Rejections + } + + return m +} diff --git a/internal/notification/service.go b/internal/notification/service.go index 4024896..1d0a472 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -2,6 +2,9 @@ package notification import ( "context" + "time" + + "golang.org/x/sync/errgroup" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/logger" @@ -117,6 +120,8 @@ func (s *service) registerSenders() { switch n.Type { case domain.NotificationTypeDiscord: s.senders = append(s.senders, NewDiscordSender(s.log, n)) + case domain.NotificationTypeNotifiarr: + s.senders = append(s.senders, NewNotifiarrSender(s.log, n)) case domain.NotificationTypeTelegram: s.senders = append(s.senders, NewTelegramSender(s.log, n)) } @@ -147,15 +152,106 @@ func (s *service) Send(event domain.NotificationEvent, payload domain.Notificati func (s *service) Test(ctx context.Context, notification domain.Notification) error { var agent domain.NotificationSender + // send test events + events := []domain.NotificationPayload{ + { + Subject: "Test Notification", + Message: "autobrr goes brr!!", + Event: domain.NotificationEventTest, + Timestamp: time.Now(), + }, + { + Subject: "New release!", + Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Event: domain.NotificationEventPushApproved, + ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Filter: "TV", + Indexer: "MockIndexer", + Status: domain.ReleasePushStatusApproved, + Action: "Send to qBittorrent", + ActionType: domain.ActionTypeQbittorrent, + ActionClient: "qBittorrent", + Rejections: nil, + Protocol: domain.ReleaseProtocolTorrent, + Implementation: domain.ReleaseImplementationIRC, + Timestamp: time.Now(), + }, + { + Subject: "New release!", + Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Event: domain.NotificationEventPushRejected, + ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Filter: "TV", + Indexer: "MockIndexer", + Status: domain.ReleasePushStatusRejected, + Action: "Send to Sonarr", + ActionType: domain.ActionTypeSonarr, + ActionClient: "Sonarr", + Rejections: []string{"Unknown Series"}, + Protocol: domain.ReleaseProtocolTorrent, + Implementation: domain.ReleaseImplementationIRC, + Timestamp: time.Now(), + }, + { + Subject: "New release!", + Message: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Event: domain.NotificationEventPushError, + ReleaseName: "Best.Show.Ever.S18E21.1080p.AMZN.WEB-DL.DDP2.0.H.264-GROUP", + Filter: "TV", + Indexer: "MockIndexer", + Status: domain.ReleasePushStatusErr, + Action: "Send to Sonarr", + ActionType: domain.ActionTypeSonarr, + ActionClient: "Sonarr", + Rejections: []string{"error pushing to client"}, + Protocol: domain.ReleaseProtocolTorrent, + Implementation: domain.ReleaseImplementationIRC, + Timestamp: time.Now(), + }, + { + Subject: "IRC Disconnected unexpectedly", + Message: "Network: P2P-Network", + Event: domain.NotificationEventIRCDisconnected, + Timestamp: time.Now(), + }, + { + Subject: "IRC Reconnected", + Message: "Network: P2P-Network", + Event: domain.NotificationEventIRCReconnected, + Timestamp: time.Now(), + }, + { + Subject: "New update available!", + Message: "v1.6.0", + Event: domain.NotificationEventAppUpdateAvailable, + Timestamp: time.Now(), + }, + } + switch notification.Type { case domain.NotificationTypeDiscord: agent = NewDiscordSender(s.log, notification) + case domain.NotificationTypeNotifiarr: + agent = NewNotifiarrSender(s.log, notification) case domain.NotificationTypeTelegram: agent = NewTelegramSender(s.log, notification) } - return agent.Send(domain.NotificationEventTest, domain.NotificationPayload{ - Subject: "Test Notification", - Message: "autobrr goes brr!!", - }) + g, ctx := errgroup.WithContext(ctx) + + for _, event := range events { + e := event + g.Go(func() error { + return agent.Send(e.Event, e) + }) + + time.Sleep(1 * time.Second) + } + + if err := g.Wait(); err != nil { + s.log.Error().Err(err).Msgf("Something went wrong sending test notifications to %v", notification.Type) + return err + } + + return nil } diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index 75e4a93..792bdfa 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -263,6 +263,11 @@ export interface OptionBasic { value: string; } +export interface OptionBasicTyped { + label: string; + value: T; +} + export const PushStatusOptions: OptionBasic[] = [ { label: "Rejected", @@ -278,11 +283,15 @@ export const PushStatusOptions: OptionBasic[] = [ } ]; -export const NotificationTypeOptions: OptionBasic[] = [ +export const NotificationTypeOptions: OptionBasicTyped[] = [ { label: "Discord", value: "DISCORD" }, + { + label: "Notifiarr", + value: "NOTIFIARR" + }, { label: "Telegram", value: "TELEGRAM" diff --git a/web/src/forms/settings/NotificationForms.tsx b/web/src/forms/settings/NotificationForms.tsx index ced4d36..5055bb3 100644 --- a/web/src/forms/settings/NotificationForms.tsx +++ b/web/src/forms/settings/NotificationForms.tsx @@ -1,14 +1,10 @@ import { Dialog, Transition } from "@headlessui/react"; import { Fragment } from "react"; -import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import type { FieldProps } from "formik"; +import { Field, Form, Formik, FormikErrors, FormikValues } from "formik"; import { XIcon } from "@heroicons/react/solid"; import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } from "react-select"; -import { - PasswordFieldWide, - SwitchGroupWide, - TextFieldWide -} from "../../components/inputs"; +import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "../../components/inputs"; import DEBUG from "../../components/debug"; import { EventOptions, NotificationTypeOptions, SelectOption } from "../../domain/constants"; import { useMutation } from "react-query"; @@ -80,6 +76,25 @@ function FormFieldsDiscord() { ); } +function FormFieldsNotifiarr() { + return ( +
+
+ Settings +

+ Enable the autobrr integration and optionally create a new API Key. +

+
+ + +
+ ); +} + function FormFieldsTelegram() { return (
@@ -105,8 +120,9 @@ function FormFieldsTelegram() { } const componentMap: componentMapType = { - DISCORD: , - TELEGRAM: + DISCORD: , + NOTIFIARR: , + TELEGRAM: }; interface NotificationAddFormValues { @@ -428,6 +444,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP name: notification.name, webhook: notification.webhook, token: notification.token, + api_key: notification.api_key, channel: notification.channel, events: notification.events || [] }; diff --git a/web/src/screens/settings/Notifications.tsx b/web/src/screens/settings/Notifications.tsx index 5cdcc76..b112cb1 100644 --- a/web/src/screens/settings/Notifications.tsx +++ b/web/src/screens/settings/Notifications.tsx @@ -80,6 +80,7 @@ const TelegramIcon = () => ( const iconComponentMap: componentMapType = { DISCORD: Discord, + NOTIFIARR: Notifiarr, TELEGRAM: Telegram }; diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index 10aa36b..8ef591f 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -1,4 +1,4 @@ -type NotificationType = "DISCORD" | "TELEGRAM"; +type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM"; type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED" | "PUSH_ERROR" | "IRC_DISCONNECTED" | "IRC_RECONNECTED" | "APP_UPDATE_AVAILABLE"; interface Notification { @@ -9,5 +9,6 @@ interface Notification { events: NotificationEvent[]; webhook?: string; token?: string; + api_key?: string; channel?: string; } \ No newline at end of file