mirror of
https://github.com/idanoo/autobrr
synced 2025-07-23 16:59:12 +00:00
feat(notifications): add ntfy support (#1323)
* feat(notifications): add ntfy support * fix(test): update * fix: added missing semicolon
This commit is contained in:
parent
3234f0d919
commit
3dd1629a3f
10 changed files with 197 additions and 17 deletions
|
@ -80,6 +80,7 @@ const (
|
||||||
NotificationTypeSlack NotificationType = "SLACK"
|
NotificationTypeSlack NotificationType = "SLACK"
|
||||||
NotificationTypeTelegram NotificationType = "TELEGRAM"
|
NotificationTypeTelegram NotificationType = "TELEGRAM"
|
||||||
NotificationTypeGotify NotificationType = "GOTIFY"
|
NotificationTypeGotify NotificationType = "GOTIFY"
|
||||||
|
NotificationTypeNtfy NotificationType = "NTFY"
|
||||||
NotificationTypeLunaSea NotificationType = "LUNASEA"
|
NotificationTypeLunaSea NotificationType = "LUNASEA"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -91,8 +91,6 @@ func setupAuthHandler() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerLogin(t *testing.T) {
|
func TestAuthHandlerLogin(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := zerolog.Nop()
|
logger := zerolog.Nop()
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
cookieStore := sessions.NewCookieStore([]byte("test"))
|
cookieStore := sessions.NewCookieStore([]byte("test"))
|
||||||
|
@ -156,8 +154,6 @@ func TestAuthHandlerLogin(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerValidateOK(t *testing.T) {
|
func TestAuthHandlerValidateOK(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := zerolog.Nop()
|
logger := zerolog.Nop()
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
cookieStore := sessions.NewCookieStore([]byte("test"))
|
cookieStore := sessions.NewCookieStore([]byte("test"))
|
||||||
|
@ -233,8 +229,6 @@ func TestAuthHandlerValidateOK(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerValidateBad(t *testing.T) {
|
func TestAuthHandlerValidateBad(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := zerolog.Nop()
|
logger := zerolog.Nop()
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
cookieStore := sessions.NewCookieStore([]byte("test"))
|
cookieStore := sessions.NewCookieStore([]byte("test"))
|
||||||
|
@ -284,8 +278,6 @@ func TestAuthHandlerValidateBad(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerLoginBad(t *testing.T) {
|
func TestAuthHandlerLoginBad(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := zerolog.Nop()
|
logger := zerolog.Nop()
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
cookieStore := sessions.NewCookieStore([]byte("test"))
|
cookieStore := sessions.NewCookieStore([]byte("test"))
|
||||||
|
@ -335,8 +327,6 @@ func TestAuthHandlerLoginBad(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAuthHandlerLogout(t *testing.T) {
|
func TestAuthHandlerLogout(t *testing.T) {
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
logger := zerolog.Nop()
|
logger := zerolog.Nop()
|
||||||
encoder := encoder{}
|
encoder := encoder{}
|
||||||
cookieStore := sessions.NewCookieStore([]byte("test"))
|
cookieStore := sessions.NewCookieStore([]byte("test"))
|
||||||
|
@ -419,7 +409,7 @@ func TestAuthHandlerLogout(t *testing.T) {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
if status := resp.StatusCode; status != http.StatusNoContent {
|
if status := resp.StatusCode; status != http.StatusNoContent {
|
||||||
t.Errorf("validate: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
t.Errorf("logout: handler returned wrong status code: got %v want %v", status, http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
//if v := resp.Header.Get("Set-Cookie"); v != "" {
|
//if v := resp.Header.Get("Set-Cookie"); v != "" {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/autobrr/autobrr/internal/domain"
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -26,12 +27,11 @@ func (b *NotificationBuilderPlainText) BuildBody(payload domain.NotificationPayl
|
||||||
buildPart(payload.Status != "", "\nStatus: %v", payload.Status.String())
|
buildPart(payload.Status != "", "\nStatus: %v", payload.Status.String())
|
||||||
buildPart(payload.Indexer != "", "\nIndexer: %v", payload.Indexer)
|
buildPart(payload.Indexer != "", "\nIndexer: %v", payload.Indexer)
|
||||||
buildPart(payload.Filter != "", "\nFilter: %v", payload.Filter)
|
buildPart(payload.Filter != "", "\nFilter: %v", payload.Filter)
|
||||||
buildPart(payload.Action != "", "\nAction: %v Type: %v", payload.Action, payload.ActionType)
|
buildPart(payload.Action != "", "\nAction: %v: %v", payload.ActionType, payload.Action)
|
||||||
buildPart(len(payload.Rejections) > 0, "\nRejections: %v", strings.Join(payload.Rejections, ", "))
|
|
||||||
|
|
||||||
if payload.Action != "" && payload.ActionClient != "" {
|
if payload.Action != "" && payload.ActionClient != "" {
|
||||||
parts = append(parts, fmt.Sprintf(" Client: %v", payload.ActionClient))
|
parts = append(parts, fmt.Sprintf(" Client: %v", payload.ActionClient))
|
||||||
}
|
}
|
||||||
|
buildPart(len(payload.Rejections) > 0, "\nRejections: %v", strings.Join(payload.Rejections, ", "))
|
||||||
|
|
||||||
return strings.Join(parts, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ func (b *NotificationBuilderPlainText) BuildTitle(event domain.NotificationEvent
|
||||||
domain.NotificationEventAppUpdateAvailable: "Autobrr update available",
|
domain.NotificationEventAppUpdateAvailable: "Autobrr update available",
|
||||||
domain.NotificationEventPushApproved: "Push Approved",
|
domain.NotificationEventPushApproved: "Push Approved",
|
||||||
domain.NotificationEventPushRejected: "Push Rejected",
|
domain.NotificationEventPushRejected: "Push Rejected",
|
||||||
domain.NotificationEventPushError: "Error",
|
domain.NotificationEventPushError: "Push Error",
|
||||||
domain.NotificationEventIRCDisconnected: "IRC Disconnected",
|
domain.NotificationEventIRCDisconnected: "IRC Disconnected",
|
||||||
domain.NotificationEventIRCReconnected: "IRC Reconnected",
|
domain.NotificationEventIRCReconnected: "IRC Reconnected",
|
||||||
domain.NotificationEventTest: "Test",
|
domain.NotificationEventTest: "Test",
|
||||||
|
|
131
internal/notification/ntfy.go
Normal file
131
internal/notification/ntfy.go
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
// Copyright (c) 2021 - 2023, Ludvig Lundgren and the autobrr contributors.
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/autobrr/autobrr/internal/domain"
|
||||||
|
"github.com/autobrr/autobrr/pkg/errors"
|
||||||
|
"github.com/autobrr/autobrr/pkg/sharedhttp"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ntfyMessage struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ntfySender struct {
|
||||||
|
log zerolog.Logger
|
||||||
|
Settings domain.Notification
|
||||||
|
builder NotificationBuilderPlainText
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewNtfySender(log zerolog.Logger, settings domain.Notification) domain.NotificationSender {
|
||||||
|
return &ntfySender{
|
||||||
|
log: log.With().Str("sender", "ntfy").Logger(),
|
||||||
|
Settings: settings,
|
||||||
|
builder: NotificationBuilderPlainText{},
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: time.Second * 30,
|
||||||
|
Transport: sharedhttp.Transport,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ntfySender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error {
|
||||||
|
m := ntfyMessage{
|
||||||
|
Message: s.builder.BuildBody(payload),
|
||||||
|
Title: s.builder.BuildTitle(event),
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, s.Settings.Host, strings.NewReader(m.Message))
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error().Err(err).Msgf("ntfy client request error: %v", event)
|
||||||
|
return errors.Wrap(err, "could not create request")
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "text/pain")
|
||||||
|
req.Header.Set("User-Agent", "autobrr")
|
||||||
|
|
||||||
|
req.Header.Set("Title", m.Title)
|
||||||
|
if s.Settings.Priority > 0 {
|
||||||
|
req.Header.Set("Priority", strconv.Itoa(int(s.Settings.Priority)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// set basic auth or access token
|
||||||
|
if s.Settings.Username != "" && s.Settings.Password != "" {
|
||||||
|
req.SetBasicAuth(s.Settings.Username, s.Settings.Password)
|
||||||
|
} else if s.Settings.Token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+s.Settings.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error().Err(err).Msgf("ntfy client request error: %v", event)
|
||||||
|
return errors.Wrap(err, "could not make request: %+v", req)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(res.Body)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error().Err(err).Msgf("ntfy client request error: %v", event)
|
||||||
|
return errors.Wrap(err, "could not read data")
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Trace().Msgf("ntfy status: %v response: %v", res.StatusCode, string(body))
|
||||||
|
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
s.log.Error().Err(err).Msgf("ntfy 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 ntfy")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ntfySender) CanSend(event domain.NotificationEvent) bool {
|
||||||
|
if s.isEnabled() && s.isEnabledEvent(event) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ntfySender) isEnabled() bool {
|
||||||
|
if s.Settings.Enabled {
|
||||||
|
if s.Settings.Host == "" {
|
||||||
|
s.log.Warn().Msg("ntfy missing host")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Settings.Token == "" {
|
||||||
|
s.log.Warn().Msg("ntfy missing application token")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ntfySender) isEnabledEvent(event domain.NotificationEvent) bool {
|
||||||
|
for _, e := range s.Settings.Events {
|
||||||
|
if e == string(event) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
|
@ -249,6 +249,8 @@ func (s *service) Test(ctx context.Context, notification domain.Notification) er
|
||||||
agent = NewPushoverSender(s.log, notification)
|
agent = NewPushoverSender(s.log, notification)
|
||||||
case domain.NotificationTypeGotify:
|
case domain.NotificationTypeGotify:
|
||||||
agent = NewGotifySender(s.log, notification)
|
agent = NewGotifySender(s.log, notification)
|
||||||
|
case domain.NotificationTypeNtfy:
|
||||||
|
agent = NewNtfySender(s.log, notification)
|
||||||
case domain.NotificationTypeLunaSea:
|
case domain.NotificationTypeLunaSea:
|
||||||
agent = NewLunaSeaSender(s.log, notification)
|
agent = NewLunaSeaSender(s.log, notification)
|
||||||
default:
|
default:
|
||||||
|
|
|
@ -406,6 +406,10 @@ export const NotificationTypeOptions: OptionBasicTyped<NotificationType>[] = [
|
||||||
label: "Gotify",
|
label: "Gotify",
|
||||||
value: "GOTIFY"
|
value: "GOTIFY"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Ntfy",
|
||||||
|
value: "NTFY"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "LunaSea",
|
label: "LunaSea",
|
||||||
value: "LUNASEA"
|
value: "LUNASEA"
|
||||||
|
|
|
@ -197,12 +197,55 @@ function FormFieldsGotify() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FormFieldsNtfy() {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 py-4">
|
||||||
|
<div className="px-4 space-y-1">
|
||||||
|
<Dialog.Title className="text-lg font-medium text-gray-900 dark:text-white">Settings</Dialog.Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextFieldWide
|
||||||
|
name="host"
|
||||||
|
label="NTFY URL"
|
||||||
|
help="NTFY URL"
|
||||||
|
placeholder="https://ntfy.sh/mytopic"
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextFieldWide
|
||||||
|
name="username"
|
||||||
|
label="Username"
|
||||||
|
help="Username"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordFieldWide
|
||||||
|
name="password"
|
||||||
|
label="Password"
|
||||||
|
help="Password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordFieldWide
|
||||||
|
name="token"
|
||||||
|
label="Access token"
|
||||||
|
help="Access token. Use this or Usernmae+password"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NumberFieldWide
|
||||||
|
name="priority"
|
||||||
|
label="Priority"
|
||||||
|
help="Max 5, 4, 3 (default), 2, 1 Min"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const componentMap: componentMapType = {
|
const componentMap: componentMapType = {
|
||||||
DISCORD: <FormFieldsDiscord />,
|
DISCORD: <FormFieldsDiscord />,
|
||||||
NOTIFIARR: <FormFieldsNotifiarr />,
|
NOTIFIARR: <FormFieldsNotifiarr />,
|
||||||
TELEGRAM: <FormFieldsTelegram />,
|
TELEGRAM: <FormFieldsTelegram />,
|
||||||
PUSHOVER: <FormFieldsPushover />,
|
PUSHOVER: <FormFieldsPushover />,
|
||||||
GOTIFY: <FormFieldsGotify />,
|
GOTIFY: <FormFieldsGotify />,
|
||||||
|
NTFY: <FormFieldsNtfy />,
|
||||||
LUNASEA: <FormFieldsLunaSea />
|
LUNASEA: <FormFieldsLunaSea />
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -15,7 +15,7 @@ import toast from "react-hot-toast";
|
||||||
import { Section } from "./_components";
|
import { Section } from "./_components";
|
||||||
import { PlusIcon } from "@heroicons/react/24/solid";
|
import { PlusIcon } from "@heroicons/react/24/solid";
|
||||||
import { Checkbox } from "@components/Checkbox";
|
import { Checkbox } from "@components/Checkbox";
|
||||||
import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, PushoverIcon, TelegramIcon } from "./_components";
|
import { DiscordIcon, GotifyIcon, LunaSeaIcon, NotifiarrIcon, NtfyIcon, PushoverIcon, TelegramIcon } from "./_components";
|
||||||
|
|
||||||
export const notificationKeys = {
|
export const notificationKeys = {
|
||||||
all: ["notifications"] as const,
|
all: ["notifications"] as const,
|
||||||
|
@ -76,6 +76,7 @@ const iconComponentMap: componentMapType = {
|
||||||
TELEGRAM: <span className={iconStyle}><TelegramIcon /> Telegram</span>,
|
TELEGRAM: <span className={iconStyle}><TelegramIcon /> Telegram</span>,
|
||||||
PUSHOVER: <span className={iconStyle}><PushoverIcon /> Pushover</span>,
|
PUSHOVER: <span className={iconStyle}><PushoverIcon /> Pushover</span>,
|
||||||
GOTIFY: <span className={iconStyle}><GotifyIcon /> Gotify</span>,
|
GOTIFY: <span className={iconStyle}><GotifyIcon /> Gotify</span>,
|
||||||
|
NTFY: <span className={iconStyle}><NtfyIcon /> ntfy</span>,
|
||||||
LUNASEA: <span className={iconStyle}><LunaSeaIcon /> LunaSea</span>
|
LUNASEA: <span className={iconStyle}><LunaSeaIcon /> LunaSea</span>
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -127,6 +127,14 @@ export const GotifyIcon = () => (
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const NtfyIcon = () => (
|
||||||
|
<svg {...commonSVGProps} viewBox="0 0 50.8 50.8" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M44.98 39.952V10.848H7.407v27.814l-1.587 4.2 8.393-2.91Z" />
|
||||||
|
<path d="M27.781 31.485h8.202" />
|
||||||
|
<path d="m65.979 100.011 9.511 5.492-9.511 5.491" transform="translate(-51.81 -80.758)"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const LunaSeaIcon = () => (
|
export const LunaSeaIcon = () => (
|
||||||
<svg {...commonSVGProps} viewBox="0 0 750 750">
|
<svg {...commonSVGProps} viewBox="0 0 750 750">
|
||||||
<path d="m554.69 180.46c-333.63 0-452.75 389.23-556.05 389.23 185.37 0 237.85-247.18 419.12-247.18l47.24-102.05z"/>
|
<path d="m554.69 180.46c-333.63 0-452.75 389.23-556.05 389.23 185.37 0 237.85-247.18 419.12-247.18l47.24-102.05z"/>
|
||||||
|
|
2
web/src/types/Notification.d.ts
vendored
2
web/src/types/Notification.d.ts
vendored
|
@ -3,7 +3,7 @@
|
||||||
* SPDX-License-Identifier: GPL-2.0-or-later
|
* SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
*/
|
*/
|
||||||
|
|
||||||
type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM" | "PUSHOVER" | "GOTIFY" | "LUNASEA";
|
type NotificationType = "DISCORD" | "NOTIFIARR" | "TELEGRAM" | "PUSHOVER" | "GOTIFY" | "NTFY" | "LUNASEA";
|
||||||
type NotificationEvent =
|
type NotificationEvent =
|
||||||
"PUSH_APPROVED"
|
"PUSH_APPROVED"
|
||||||
| "PUSH_REJECTED"
|
| "PUSH_REJECTED"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue