From da5492febb1606efc08cce258afe63c59991f21a Mon Sep 17 00:00:00 2001 From: Nelson Pecora Date: Sat, 29 Apr 2023 11:07:15 -0400 Subject: [PATCH] feat(notifications): add Pushover (#598) * feat(notifications): add pushover * add db migration * fix lint error * some small corrections * fixed README * added missing columns to postgres_migrate.go * use token for user_key * refactor(notifications): change priority to int * fix: only test selected events --------- Co-authored-by: soup Co-authored-by: ze0s --- README.md | 2 +- internal/database/notification.go | 36 ++-- internal/database/postgres_migrate.go | 3 + internal/database/sqlite_migrate.go | 3 + internal/domain/notification.go | 1 + internal/http/notification.go | 4 +- internal/notification/pushover.go | 192 +++++++++++++++++++ internal/notification/service.go | 26 ++- web/src/domain/constants.ts | 4 + web/src/forms/settings/NotificationForms.tsx | 39 +++- web/src/screens/settings/Notifications.tsx | 12 +- web/src/types/Notification.d.ts | 5 +- 12 files changed, 290 insertions(+), 37 deletions(-) create mode 100644 internal/notification/pushover.go diff --git a/README.md b/README.md index 5565b4e..f9dfa9b 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Installation guide and documentation can be found at https://autobrr.com - Built on Go and React making autobrr lightweight and perfect for supporting multiple platforms (Linux, FreeBSD, Windows, macOS) on different architectures (e.g. x86, ARM) - Great container support (Docker, k8s/Kubernetes) - Database engine supporting both PostgreSQL and SQLite -- Notifications (Discord, Telegram, Notifiarr) +- Notifications (Discord, Telegram, Notifiarr, Pushover) - One autobrr instance can communicate with multiple clients (torrent, usenet and \*arr) on remote servers - Base path / Subfolder (and subdomain) support for convenient reverse-proxy support diff --git a/internal/database/notification.go b/internal/database/notification.go index 80c9ebb..af8da1b 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", "api_key", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). + Select("id", "name", "type", "enabled", "events", "webhook", "token", "api_key", "channel", "priority", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). From("notification"). OrderBy("name") @@ -50,10 +50,8 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ var n domain.Notification 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, &apiKey, &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.Priority, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil { return nil, 0, errors.Wrap(err, "error scanning row") } @@ -61,14 +59,6 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ n.Webhook = webhook.String n.Token = token.String n.Channel = channel.String - //n.Title = title.String - //n.Icon = icon.String - //n.Host = host.String - //n.Username = username.String - //n.Password = password.String - //n.Channel = channel.String - //n.Targets = targets.String - //n.Devices = devices.String notifications = append(notifications, n) } @@ -81,7 +71,7 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ func (r *NotificationRepo) List(ctx context.Context) ([]domain.Notification, error) { - rows, err := r.db.handler.QueryContext(ctx, "SELECT id, name, type, enabled, events, token, api_key, webhook, title, icon, host, username, password, channel, targets, devices, created_at, updated_at FROM notification ORDER BY name ASC") + rows, err := r.db.handler.QueryContext(ctx, "SELECT id, name, type, enabled, events, token, api_key, webhook, title, icon, host, username, password, channel, targets, devices, priority,created_at, updated_at FROM notification ORDER BY name ASC") if err != nil { return nil, errors.Wrap(err, "error executing query") } @@ -94,7 +84,7 @@ func (r *NotificationRepo) List(ctx context.Context) ([]domain.Notification, err //var eventsSlice []string 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 { + 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.Priority, &n.CreatedAt, &n.UpdatedAt); err != nil { return nil, errors.Wrap(err, "error scanning row") } @@ -140,6 +130,7 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi "channel", "targets", "devices", + "priority", "created_at", "updated_at", ). @@ -151,7 +142,6 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi return nil, errors.Wrap(err, "error building query") } - //row := r.db.handler.QueryRowContext(ctx, "SELECT id, name, type, enabled, events, token, api_key, webhook, title, icon, host, username, password, channel, targets, devices, created_at, updated_at FROM notification WHERE id = ?", id) row := r.db.handler.QueryRowContext(ctx, query, args...) if err := row.Err(); err != nil { return nil, errors.Wrap(err, "error executing query") @@ -160,7 +150,7 @@ func (r *NotificationRepo) FindByID(ctx context.Context, id int) (*domain.Notifi var n domain.Notification var token, apiKey, webhook, title, icon, host, username, password, channel, targets, devices sql.NullString - if err := row.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 { + if err := row.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.Priority, &n.CreatedAt, &n.UpdatedAt); err != nil { return nil, errors.Wrap(err, "error scanning row") } @@ -196,6 +186,7 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi "token", "api_key", "channel", + "priority", ). Values( notification.Name, @@ -206,14 +197,14 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi token, apiKey, channel, + notification.Priority, ). Suffix("RETURNING id").RunWith(r.db.handler) // return values var retID int64 - err := queryBuilder.QueryRowContext(ctx).Scan(&retID) - if err != nil { + if err := queryBuilder.QueryRowContext(ctx).Scan(&retID); err != nil { return nil, errors.Wrap(err, "error executing query") } @@ -239,6 +230,7 @@ func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notif Set("token", token). Set("api_key", apiKey). Set("channel", channel). + Set("priority", notification.Priority). Set("updated_at", sq.Expr("CURRENT_TIMESTAMP")). Where(sq.Eq{"id": notification.ID}) @@ -247,8 +239,7 @@ func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notif return nil, errors.Wrap(err, "error building query") } - _, err = r.db.handler.ExecContext(ctx, query, args...) - if err != nil { + if _, err = r.db.handler.ExecContext(ctx, query, args...); err != nil { return nil, errors.Wrap(err, "error executing query") } @@ -267,8 +258,7 @@ func (r *NotificationRepo) Delete(ctx context.Context, notificationID int) error return errors.Wrap(err, "error building query") } - _, err = r.db.handler.ExecContext(ctx, query, args...) - if err != nil { + if _, err = r.db.handler.ExecContext(ctx, query, args...); err != nil { return errors.Wrap(err, "error executing query") } diff --git a/internal/database/postgres_migrate.go b/internal/database/postgres_migrate.go index cd8815c..397e11a 100644 --- a/internal/database/postgres_migrate.go +++ b/internal/database/postgres_migrate.go @@ -300,6 +300,7 @@ CREATE TABLE notification rooms TEXT, targets TEXT, devices TEXT, + priority INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -671,4 +672,6 @@ ADD COLUMN download_url TEXT; SET except_tags_match_logic = 'ANY' WHERE except_tags IS NOT NULL; `, + `ALTER TABLE notification +ADD COLUMN priority INTEGER DEFAULT 0;`, } diff --git a/internal/database/sqlite_migrate.go b/internal/database/sqlite_migrate.go index b937daf..04ac399 100644 --- a/internal/database/sqlite_migrate.go +++ b/internal/database/sqlite_migrate.go @@ -292,6 +292,7 @@ CREATE TABLE notification rooms TEXT, targets TEXT, devices TEXT, + priority INTEGER DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); @@ -1064,4 +1065,6 @@ ADD COLUMN download_url TEXT; SET except_tags_match_logic = 'ANY' WHERE except_tags IS NOT NULL; `, + `ALTER TABLE notification +ADD COLUMN priority INTEGER DEFAULT 0;`, } diff --git a/internal/domain/notification.go b/internal/domain/notification.go index f833bec..9a7de87 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -37,6 +37,7 @@ type Notification struct { Rooms string `json:"rooms"` Targets string `json:"targets"` Devices string `json:"devices"` + Priority int32 `json:"priority"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/http/notification.go b/internal/http/notification.go index 1baec10..0924942 100644 --- a/internal/http/notification.go +++ b/internal/http/notification.go @@ -114,13 +114,11 @@ func (h notificationHandler) test(w http.ResponseWriter, r *http.Request) { ) if err := json.NewDecoder(r.Body).Decode(&data); err != nil { - // encode error h.encoder.Error(w, err) return } - err := h.service.Test(ctx, data) - if err != nil { + if err := h.service.Test(ctx, data); err != nil { h.encoder.Error(w, err) return } diff --git a/internal/notification/pushover.go b/internal/notification/pushover.go new file mode 100644 index 0000000..50d374e --- /dev/null +++ b/internal/notification/pushover.go @@ -0,0 +1,192 @@ +package notification + +import ( + "fmt" + "html" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/pkg/errors" + + "github.com/rs/zerolog" +) + +type pushoverMessage struct { + Token string `json:"api_key"` + User string `json:"token"` + Message string `json:"message"` + Priority int32 `json:"priority"` + Title string `json:"title"` + Timestamp time.Time `json:"timestamp"` + Html int `json:"html,omitempty"` +} + +type pushoverSender struct { + log zerolog.Logger + Settings domain.Notification + baseUrl string +} + +func NewPushoverSender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender { + return &pushoverSender{ + log: log.With().Str("sender", "pushover").Logger(), + Settings: settings, + baseUrl: "https://api.pushover.net/1/messages.json", + } +} + +func (s *pushoverSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + m := pushoverMessage{ + Token: s.Settings.APIKey, + User: s.Settings.Token, + Priority: s.Settings.Priority, + Message: s.buildMessage(payload), + Title: s.buildTitle(event), + Timestamp: time.Now(), + Html: 1, + } + + data := url.Values{} + data.Set("token", m.Token) + data.Set("user", m.User) + data.Set("message", m.Message) + data.Set("priority", strconv.Itoa(int(m.Priority))) + data.Set("title", m.Title) + data.Set("timestamp", fmt.Sprintf("%v", m.Timestamp.Unix())) + data.Set("html", fmt.Sprintf("%v", m.Html)) + + if m.Priority == 2 { + data.Set("expire", "3600") + data.Set("retry", "60") + } + + req, err := http.NewRequest(http.MethodPost, s.baseUrl, strings.NewReader(data.Encode())) + if err != nil { + s.log.Error().Err(err).Msgf("pushover client request error: %v", event) + return errors.Wrap(err, "could not create request") + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("User-Agent", "autobrr") + + client := http.Client{Timeout: 30 * time.Second} + res, err := client.Do(req) + if err != nil { + s.log.Error().Err(err).Msgf("pushover 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("pushover client request error: %v", event) + return errors.Wrap(err, "could not read data") + } + + defer res.Body.Close() + + s.log.Trace().Msgf("pushover status: %v response: %v", res.StatusCode, string(body)) + + if res.StatusCode != http.StatusOK { + s.log.Error().Err(err).Msgf("pushover 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 pushover") + + return nil +} + +func (s *pushoverSender) CanSend(event domain.NotificationEvent) bool { + if s.isEnabled() && s.isEnabledEvent(event) { + return true + } + return false +} + +func (s *pushoverSender) isEnabled() bool { + if s.Settings.Enabled { + if s.Settings.APIKey == "" { + s.log.Warn().Msg("pushover missing api key") + return false + } + + if s.Settings.Token == "" { + s.log.Warn().Msg("pushover missing user key") + return false + } + + return true + } + + return false +} + +func (s *pushoverSender) isEnabledEvent(event domain.NotificationEvent) bool { + for _, e := range s.Settings.Events { + if e == string(event) { + return true + } + } + + 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.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 8433d35..118dd35 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -125,6 +125,8 @@ func (s *service) registerSenders() { s.senders = append(s.senders, NewNotifiarrSender(s.log, n)) case domain.NotificationTypeTelegram: s.senders = append(s.senders, NewTelegramSender(s.log, n)) + case domain.NotificationTypePushover: + s.senders = append(s.senders, NewPushoverSender(s.log, n)) } } } @@ -236,6 +238,8 @@ func (s *service) Test(ctx context.Context, notification domain.Notification) er agent = NewNotifiarrSender(s.log, notification) case domain.NotificationTypeTelegram: agent = NewTelegramSender(s.log, notification) + case domain.NotificationTypePushover: + agent = NewPushoverSender(s.log, notification) default: s.log.Error().Msgf("unsupported notification type: %v", notification.Type) return errors.New("unsupported notification type") @@ -245,9 +249,15 @@ func (s *service) Test(ctx context.Context, notification domain.Notification) er for _, event := range events { e := event - g.Go(func() error { - return agent.Send(e.Event, e) - }) + + if !enabledEvent(notification.Events, e.Event) { + continue + } + + if err := agent.Send(e.Event, e); err != nil { + s.log.Error().Err(err).Msgf("error sending test notification: %#v", notification) + return err + } time.Sleep(1 * time.Second) } @@ -259,3 +269,13 @@ func (s *service) Test(ctx context.Context, notification domain.Notification) er return nil } + +func enabledEvent(events []string, e domain.NotificationEvent) bool { + for _, v := range events { + if v == string(e) { + return true + } + } + + return false +} diff --git a/web/src/domain/constants.ts b/web/src/domain/constants.ts index f343959..ab26b4e 100644 --- a/web/src/domain/constants.ts +++ b/web/src/domain/constants.ts @@ -386,6 +386,10 @@ export const NotificationTypeOptions: OptionBasicTyped[] = [ { label: "Telegram", value: "TELEGRAM" + }, + { + label: "Pushover", + value: "PUSHOVER" } ]; diff --git a/web/src/forms/settings/NotificationForms.tsx b/web/src/forms/settings/NotificationForms.tsx index fa08398..bf437b3 100644 --- a/web/src/forms/settings/NotificationForms.tsx +++ b/web/src/forms/settings/NotificationForms.tsx @@ -7,7 +7,7 @@ import Select, { components, ControlProps, InputProps, MenuProps, OptionProps } import { useMutation, useQueryClient } from "@tanstack/react-query"; import { toast } from "react-hot-toast"; -import { PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs"; +import { NumberFieldWide, PasswordFieldWide, SwitchGroupWide, TextFieldWide } from "@components/inputs"; import DEBUG from "@components/debug"; import { EventOptions, NotificationTypeOptions, SelectOption } from "@domain/constants"; import { APIClient } from "@api/APIClient"; @@ -120,10 +120,41 @@ function FormFieldsTelegram() { ); } +function FormFieldsPushover() { + return ( +
+
+ Settings +

+ Register a new application and add its API Token here. +

+
+ + + + +
+ ); +} + const componentMap: componentMapType = { DISCORD: , NOTIFIARR: , - TELEGRAM: + TELEGRAM: , + PUSHOVER: }; interface NotificationAddFormValues { @@ -398,6 +429,7 @@ interface InitialValues { webhook?: string; token?: string; api_key?: string; + priority?: number; channel?: string; events: NotificationEvent[]; } @@ -445,6 +477,7 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP webhook: notification.webhook, token: notification.token, api_key: notification.api_key, + priority: notification.priority, channel: notification.channel, events: notification.events || [] }; @@ -529,4 +562,4 @@ export function NotificationUpdateForm({ isOpen, toggle, notification }: UpdateP )} ); -} +} \ No newline at end of file diff --git a/web/src/screens/settings/Notifications.tsx b/web/src/screens/settings/Notifications.tsx index 5b9682e..2709d04 100644 --- a/web/src/screens/settings/Notifications.tsx +++ b/web/src/screens/settings/Notifications.tsx @@ -85,11 +85,19 @@ const TelegramIcon = () => ( ); +const PushoverIcon = () => ( + + + +); + const iconComponentMap: componentMapType = { DISCORD: Discord, NOTIFIARR: Notifiarr, - TELEGRAM: Telegram + TELEGRAM: Telegram, + PUSHOVER: Pushover }; interface ListItemProps { @@ -151,4 +159,4 @@ function ListItem({ notification }: ListItemProps) { ); } -export default NotificationSettings; \ No newline at end of file +export default NotificationSettings; diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index 8ef591f..43ab05b 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -1,4 +1,4 @@ -type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM"; +type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM" | "PUSHOVER"; type NotificationEvent = "PUSH_APPROVED" | "PUSH_REJECTED" | "PUSH_ERROR" | "IRC_DISCONNECTED" | "IRC_RECONNECTED" | "APP_UPDATE_AVAILABLE"; interface Notification { @@ -11,4 +11,5 @@ interface Notification { token?: string; api_key?: string; channel?: string; -} \ No newline at end of file + priority?: number; +}