diff --git a/internal/action/run.go b/internal/action/run.go index 8f9e929..bce32c2 100644 --- a/internal/action/run.go +++ b/internal/action/run.go @@ -12,51 +12,6 @@ import ( "github.com/autobrr/autobrr/internal/domain" ) -func (s *service) RunActions(actions []domain.Action, release domain.Release) error { - - for _, action := range actions { - // only run active actions - if !action.Enabled { - continue - } - - s.log.Debug().Msgf("process action: %v for '%v'", action.Name, release.TorrentName) - - err := s.runAction(action, release) - if err != nil { - s.log.Err(err).Stack().Msgf("process action failed: %v for '%v'", action.Name, release.TorrentName) - - s.bus.Publish("release:store-action-status", &domain.ReleaseActionStatus{ - ReleaseID: release.ID, - Status: domain.ReleasePushStatusErr, - Action: action.Name, - Type: action.Type, - Rejections: []string{err.Error()}, - Timestamp: time.Now(), - }) - - s.bus.Publish("events:release:push", &domain.EventsReleasePushed{ - ReleaseName: release.TorrentName, - Filter: release.Filter.Name, - Indexer: release.Indexer, - InfoHash: release.TorrentHash, - Size: release.Size, - Status: domain.ReleasePushStatusErr, - Action: action.Name, - ActionType: action.Type, - Rejections: []string{err.Error()}, - Protocol: domain.ReleaseProtocolTorrent, - Implementation: domain.ReleaseImplementationIRC, - Timestamp: time.Now(), - }) - } - } - - // safe to delete tmp file - - return nil -} - func (s *service) RunAction(action *domain.Action, release domain.Release) ([]string, error) { var err error @@ -186,7 +141,8 @@ func (s *service) RunAction(action *domain.Action, release domain.Release) ([]st Timestamp: time.Now(), } - notificationEvent := &domain.EventsReleasePushed{ + payload := &domain.NotificationPayload{ + Event: domain.NotificationEventPushApproved, ReleaseName: release.TorrentName, Filter: release.Filter.Name, Indexer: release.Indexer, @@ -208,188 +164,29 @@ func (s *service) RunAction(action *domain.Action, release domain.Release) ([]st rlsActionStatus.Status = domain.ReleasePushStatusErr rlsActionStatus.Rejections = []string{err.Error()} - notificationEvent.Status = domain.ReleasePushStatusErr - notificationEvent.Rejections = []string{err.Error()} + payload.Event = domain.NotificationEventPushError + payload.Status = domain.ReleasePushStatusErr + payload.Rejections = []string{err.Error()} } if rejections != nil { rlsActionStatus.Status = domain.ReleasePushStatusRejected rlsActionStatus.Rejections = rejections - notificationEvent.Status = domain.ReleasePushStatusRejected - notificationEvent.Rejections = rejections + payload.Event = domain.NotificationEventPushRejected + payload.Status = domain.ReleasePushStatusRejected + payload.Rejections = rejections } // send event for actions s.bus.Publish("release:push", rlsActionStatus) // send separate event for notifications - s.bus.Publish("events:release:push", notificationEvent) + s.bus.Publish("events:notification", &payload.Event, payload) return rejections, err } -func (s *service) runAction(action domain.Action, release domain.Release) error { - - var err error - var rejections []string - - switch action.Type { - case domain.ActionTypeTest: - s.test(action.Name) - - case domain.ActionTypeExec: - if release.TorrentTmpFile == "" { - if err := release.DownloadTorrentFile(); err != nil { - s.log.Error().Stack().Err(err) - return err - } - } - - s.execCmd(release, action) - - case domain.ActionTypeWatchFolder: - if release.TorrentTmpFile == "" { - if err := release.DownloadTorrentFile(); err != nil { - s.log.Error().Stack().Err(err) - return err - } - } - - s.watchFolder(action, release) - - case domain.ActionTypeWebhook: - if release.TorrentTmpFile == "" { - if err := release.DownloadTorrentFile(); err != nil { - s.log.Error().Stack().Err(err) - return err - } - } - - s.webhook(action, release) - - case domain.ActionTypeDelugeV1, domain.ActionTypeDelugeV2: - canDownload, err := s.delugeCheckRulesCanDownload(action) - if err != nil { - s.log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) - return err - } - if !canDownload { - rejections = []string{"max active downloads reached, skipping"} - break - } - - if release.TorrentTmpFile == "" { - if err := release.DownloadTorrentFile(); err != nil { - s.log.Error().Stack().Err(err) - return err - } - } - - err = s.deluge(action, release) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to Deluge") - return err - } - - case domain.ActionTypeQbittorrent: - canDownload, client, err := s.qbittorrentCheckRulesCanDownload(action) - if err != nil { - s.log.Error().Stack().Err(err).Msgf("error checking client rules: %v", action.Name) - return err - } - if !canDownload { - rejections = []string{"max active downloads reached, skipping"} - break - } - - if release.TorrentTmpFile == "" { - if err := release.DownloadTorrentFile(); err != nil { - s.log.Error().Stack().Err(err) - return err - } - } - - err = s.qbittorrent(client, action, release) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to qBittorrent") - return err - } - - case domain.ActionTypeRadarr: - rejections, err = s.radarr(release, action) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to radarr") - return err - } - - case domain.ActionTypeSonarr: - rejections, err = s.sonarr(release, action) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to sonarr") - return err - } - - case domain.ActionTypeLidarr: - rejections, err = s.lidarr(release, action) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to lidarr") - return err - } - - case domain.ActionTypeWhisparr: - rejections, err = s.whisparr(release, action) - if err != nil { - s.log.Error().Stack().Err(err).Msg("error sending torrent to whisparr") - return err - } - - default: - s.log.Warn().Msgf("unsupported action: %v type: %v", action.Name, action.Type) - return nil - } - - rlsActionStatus := &domain.ReleaseActionStatus{ - ReleaseID: release.ID, - Status: domain.ReleasePushStatusApproved, - Action: action.Name, - Type: action.Type, - Rejections: []string{}, - Timestamp: time.Now(), - } - - notificationEvent := &domain.EventsReleasePushed{ - ReleaseName: release.TorrentName, - Filter: release.Filter.Name, - Indexer: release.Indexer, - InfoHash: release.TorrentHash, - Size: release.Size, - Status: domain.ReleasePushStatusApproved, - Action: action.Name, - ActionType: action.Type, - Rejections: []string{}, - Protocol: domain.ReleaseProtocolTorrent, - Implementation: domain.ReleaseImplementationIRC, - Timestamp: time.Now(), - } - - if rejections != nil { - rlsActionStatus.Status = domain.ReleasePushStatusRejected - rlsActionStatus.Rejections = rejections - - notificationEvent.Status = domain.ReleasePushStatusRejected - notificationEvent.Rejections = rejections - } - - // send event for actions - s.bus.Publish("release:push", rlsActionStatus) - - // send separate event for notifications - s.bus.Publish("events:release:push", notificationEvent) - - return nil -} - func (s *service) CheckCanDownload(actions []domain.Action) bool { for _, action := range actions { if !action.Enabled { diff --git a/internal/action/service.go b/internal/action/service.go index 9822187..95be8a7 100644 --- a/internal/action/service.go +++ b/internal/action/service.go @@ -17,7 +17,6 @@ type Service interface { DeleteByFilterID(ctx context.Context, filterID int) error ToggleEnabled(actionID int) error - RunActions(actions []domain.Action, release domain.Release) error RunAction(action *domain.Action, release domain.Release) ([]string, error) CheckCanDownload(actions []domain.Action) bool } diff --git a/internal/database/notification.go b/internal/database/notification.go index 1a3c23e..801458f 100644 --- a/internal/database/notification.go +++ b/internal/database/notification.go @@ -26,7 +26,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", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). + Select("id", "name", "type", "enabled", "events", "webhook", "token", "channel", "created_at", "updated_at", "COUNT(*) OVER() AS total_count"). From("notification"). OrderBy("name") @@ -49,18 +49,19 @@ func (r *NotificationRepo) Find(ctx context.Context, params domain.NotificationQ for rows.Next() { var n domain.Notification - var webhook sql.NullString + var webhook, token, 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, &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, &channel, &n.CreatedAt, &n.UpdatedAt, &totalCount); err != nil { r.log.Error().Stack().Err(err).Msg("notification.find: error scanning row") return nil, 0, err } - //n.Token = token.String //n.APIKey = apiKey.String n.Webhook = webhook.String + n.Token = token.String + n.Channel = channel.String //n.Title = title.String //n.Icon = icon.String //n.Host = host.String @@ -182,6 +183,8 @@ 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) + channel := toNullString(notification.Channel) queryBuilder := r.db.squirrel. Insert("notification"). @@ -191,6 +194,8 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi "enabled", "events", "webhook", + "token", + "channel", ). Values( notification.Name, @@ -198,6 +203,8 @@ func (r *NotificationRepo) Store(ctx context.Context, notification domain.Notifi notification.Enabled, pq.Array(notification.Events), webhook, + token, + channel, ). Suffix("RETURNING id").RunWith(r.db.handler) @@ -218,6 +225,8 @@ 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) + channel := toNullString(notification.Channel) queryBuilder := r.db.squirrel. Update("notification"). @@ -226,6 +235,8 @@ func (r *NotificationRepo) Update(ctx context.Context, notification domain.Notif Set("enabled", notification.Enabled). Set("events", pq.Array(notification.Events)). Set("webhook", webhook). + Set("token", token). + 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 79a370a..9b40b05 100644 --- a/internal/domain/notification.go +++ b/internal/domain/notification.go @@ -7,7 +7,6 @@ import ( type NotificationRepo interface { List(ctx context.Context) ([]Notification, error) - //FindByType(ctx context.Context, nType NotificationType) ([]Notification, error) Find(ctx context.Context, params NotificationQueryParams) ([]Notification, int, error) FindByID(ctx context.Context, id int) (*Notification, error) Store(ctx context.Context, notification Notification) (*Notification, error) @@ -15,6 +14,11 @@ type NotificationRepo interface { Delete(ctx context.Context, notificationID int) error } +type NotificationSender interface { + Send(event NotificationEvent, payload NotificationPayload) error + CanSend(event NotificationEvent) bool +} + type Notification struct { ID int `json:"id"` Name string `json:"name"` @@ -37,6 +41,25 @@ type Notification struct { UpdatedAt time.Time `json:"updated_at"` } +type NotificationPayload struct { + Subject string + Message string + Event NotificationEvent + ReleaseName string + Filter string + Indexer string + InfoHash string + Size uint64 + Status ReleasePushStatus + Action string + ActionType ActionType + ActionClient string + Rejections []string + Protocol ReleaseProtocol // torrent + Implementation ReleaseImplementation // irc, rss, api + Timestamp time.Time +} + type NotificationType string const ( @@ -57,8 +80,10 @@ type NotificationEvent string const ( NotificationEventPushApproved NotificationEvent = "PUSH_APPROVED" NotificationEventPushRejected NotificationEvent = "PUSH_REJECTED" + NotificationEventPushError NotificationEvent = "PUSH_ERROR" NotificationEventUpdateAvailable NotificationEvent = "UPDATE_AVAILABLE" NotificationEventIRCHealth NotificationEvent = "IRC_HEALTH" + NotificationEventTest NotificationEvent = "TEST" ) type NotificationEventArr []NotificationEvent diff --git a/internal/events/subscribers.go b/internal/events/subscribers.go index 2d8cd03..6000301 100644 --- a/internal/events/subscribers.go +++ b/internal/events/subscribers.go @@ -34,7 +34,7 @@ func NewSubscribers(log logger.Logger, eventbus EventBus.Bus, notificationSvc no func (s Subscriber) Register() { s.eventbus.Subscribe("release:store-action-status", s.releaseActionStatus) s.eventbus.Subscribe("release:push", s.releasePushStatus) - s.eventbus.Subscribe("events:release:push", s.releasePush) + s.eventbus.Subscribe("events:notification", s.sendNotification) } func (s Subscriber) releaseActionStatus(actionStatus *domain.ReleaseActionStatus) { @@ -54,10 +54,10 @@ func (s Subscriber) releasePushStatus(actionStatus *domain.ReleaseActionStatus) } } -func (s Subscriber) releasePush(event *domain.EventsReleasePushed) { - s.log.Trace().Msgf("events: 'events:release:push' '%+v'", event) +func (s Subscriber) sendNotification(event *domain.NotificationEvent, payload *domain.NotificationPayload) { + s.log.Trace().Msgf("events: '%v' '%+v'", event, payload) - if err := s.notificationSvc.SendEvent(*event); err != nil { - s.log.Error().Err(err).Msgf("events: 'events:release:push' error sending notification") + if err := s.notificationSvc.Send(*event, *payload); err != nil { + s.log.Error().Err(err).Msgf("events: '%v' error sending notification", event) } } diff --git a/internal/http/notification.go b/internal/http/notification.go index 6d60a5d..ea2c619 100644 --- a/internal/http/notification.go +++ b/internal/http/notification.go @@ -17,6 +17,7 @@ type notificationService interface { Store(ctx context.Context, n domain.Notification) (*domain.Notification, error) Update(ctx context.Context, n domain.Notification) (*domain.Notification, error) Delete(ctx context.Context, id int) error + Test(ctx context.Context, notification domain.Notification) error } type notificationHandler struct { @@ -34,6 +35,7 @@ func newNotificationHandler(encoder encoder, service notificationService) *notif func (h notificationHandler) Routes(r chi.Router) { r.Get("/", h.list) r.Post("/", h.store) + r.Post("/test", h.test) r.Put("/{notificationID}", h.update) r.Delete("/{notificationID}", h.delete) } @@ -104,3 +106,24 @@ func (h notificationHandler) delete(w http.ResponseWriter, r *http.Request) { h.encoder.StatusResponse(ctx, w, nil, http.StatusNoContent) } + +func (h notificationHandler) test(w http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + data domain.Notification + ) + + 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 { + h.encoder.Error(w, err) + return + } + + h.encoder.NoContent(w) +} diff --git a/internal/notification/discord.go b/internal/notification/discord.go index dba4998..a35537a 100644 --- a/internal/notification/discord.go +++ b/internal/notification/discord.go @@ -5,16 +5,18 @@ import ( "crypto/tls" "encoding/json" "fmt" + "io/ioutil" "net/http" "strings" "time" "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/logger" ) type DiscordMessage struct { Content interface{} `json:"content"` - Embeds []DiscordEmbeds `json:"embeds"` + Embeds []DiscordEmbeds `json:"embeds,omitempty"` Username string `json:"username"` } @@ -22,7 +24,7 @@ type DiscordEmbeds struct { Title string `json:"title"` Description string `json:"description"` Color int `json:"color"` - Fields []DiscordEmbedsFields `json:"fields"` + Fields []DiscordEmbedsFields `json:"fields,omitempty"` Timestamp time.Time `json:"timestamp"` } type DiscordEmbedsFields struct { @@ -31,7 +33,46 @@ type DiscordEmbedsFields struct { Inline bool `json:"inline,omitempty"` } -func (s *service) discordNotification(event domain.EventsReleasePushed, webhookURL string) { +type EmbedColors int + +const ( + LIGHT_BLUE EmbedColors = 5814783 // 58b9ff + RED EmbedColors = 15548997 // ed4245 + GREEN EmbedColors = 5763719 // 57f287 + GRAY EmbedColors = 10070709 // 99aab5 +) + +type discordSender struct { + log logger.Logger + Settings domain.Notification +} + +func NewDiscordSender(log logger.Logger, settings domain.Notification) domain.NotificationSender { + return &discordSender{log: log, Settings: settings} +} + +func (a *discordSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + m := DiscordMessage{ + Content: nil, + Embeds: []DiscordEmbeds{a.buildEmbed(event, payload)}, + Username: "brr", + } + + jsonData, err := json.Marshal(m) + if err != nil { + a.log.Error().Err(err).Msgf("discord client could not marshal data: %v", m) + return err + } + + req, err := http.NewRequest(http.MethodPost, a.Settings.Webhook, bytes.NewBuffer(jsonData)) + if err != nil { + a.log.Error().Err(err).Msgf("discord client request error: %v", event) + return err + } + + req.Header.Set("Content-Type", "application/json") + //req.Header.Set("User-Agent", "autobrr") + t := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, @@ -39,107 +80,141 @@ func (s *service) discordNotification(event domain.EventsReleasePushed, webhookU } client := http.Client{Transport: t, Timeout: 30 * time.Second} - - color := map[domain.ReleasePushStatus]int{ - domain.ReleasePushStatusApproved: 5814783, - domain.ReleasePushStatusRejected: 5814783, - domain.ReleasePushStatusErr: 14026000, - } - - m := DiscordMessage{ - Content: nil, - Embeds: []DiscordEmbeds{ - { - Title: event.ReleaseName, - Description: "New release!", - Color: color[event.Status], - Fields: []DiscordEmbedsFields{ - { - Name: "Status", - Value: event.Status.String(), - Inline: true, - }, - { - Name: "Indexer", - Value: event.Indexer, - Inline: true, - }, - { - Name: "Filter", - Value: event.Filter, - Inline: true, - }, - { - Name: "Action", - Value: event.Action, - Inline: true, - }, - { - Name: "Action type", - Value: string(event.ActionType), - Inline: true, - }, - //{ - // Name: "Action client", - // Value: event.ActionClient, - // Inline: true, - //}, - }, - Timestamp: time.Now(), - }, - }, - Username: "brr", - } - - if event.ActionClient == "" { - rej := DiscordEmbedsFields{ - Name: "Action client", - Value: "n/a", - Inline: true, - } - m.Embeds[0].Fields = append(m.Embeds[0].Fields, rej) - } else { - rej := DiscordEmbedsFields{ - Name: "Action client", - Value: event.ActionClient, - Inline: true, - } - m.Embeds[0].Fields = append(m.Embeds[0].Fields, rej) - } - - if len(event.Rejections) > 0 { - rej := DiscordEmbedsFields{ - Name: "Reasons", - Value: fmt.Sprintf("```\n%v\n```", strings.Join(event.Rejections, " ,")), - Inline: false, - } - m.Embeds[0].Fields = append(m.Embeds[0].Fields, rej) - } - - jsonData, err := json.Marshal(m) - if err != nil { - s.log.Error().Err(err).Msgf("discord client could not marshal data: %v", m) - return - } - - req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewBuffer(jsonData)) - if err != nil { - s.log.Error().Err(err).Msgf("discord client request error: %v", event.ReleaseName) - return - } - - req.Header.Set("Content-Type", "application/json") - //req.Header.Set("User-Agent", "autobrr") - res, err := client.Do(req) if err != nil { - s.log.Error().Err(err).Msgf("discord client request error: %v", event.ReleaseName) - return + a.log.Error().Err(err).Msgf("discord client request error: %v", event) + return err + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + a.log.Error().Err(err).Msgf("discord client request error: %v", event) + return err } defer res.Body.Close() - s.log.Debug().Msg("notification successfully sent to discord") + a.log.Trace().Msgf("discord status: %v response: %v", res.StatusCode, string(body)) - return + if res.StatusCode != http.StatusNoContent { + a.log.Error().Err(err).Msgf("discord client request error: %v", string(body)) + return fmt.Errorf("err: %v", string(body)) + } + + a.log.Debug().Msg("notification successfully sent to discord") + + return nil +} + +func (a *discordSender) CanSend(event domain.NotificationEvent) bool { + if a.isEnabled() && a.isEnabledEvent(event) { + return true + } + return false +} + +func (a *discordSender) isEnabled() bool { + if a.Settings.Enabled && a.Settings.Webhook != "" { + return true + } + return false +} + +func (a *discordSender) isEnabledEvent(event domain.NotificationEvent) bool { + for _, e := range a.Settings.Events { + if e == string(event) { + return true + } + } + + return false +} + +func (a *discordSender) buildEmbed(event domain.NotificationEvent, payload domain.NotificationPayload) DiscordEmbeds { + + color := LIGHT_BLUE + switch event { + case domain.NotificationEventPushApproved: + color = GREEN + case domain.NotificationEventPushRejected: + color = GRAY + case domain.NotificationEventPushError: + color = RED + case domain.NotificationEventTest: + color = LIGHT_BLUE + } + + var fields []DiscordEmbedsFields + + if payload.Status != "" { + f := DiscordEmbedsFields{ + Name: "Status", + Value: payload.Status.String(), + Inline: true, + } + fields = append(fields, f) + } + if payload.Indexer != "" { + f := DiscordEmbedsFields{ + Name: "Indexer", + Value: payload.Indexer, + Inline: true, + } + fields = append(fields, f) + } + if payload.Filter != "" { + f := DiscordEmbedsFields{ + Name: "Filter", + Value: payload.Filter, + Inline: true, + } + fields = append(fields, f) + } + if payload.Action != "" { + f := DiscordEmbedsFields{ + Name: "Action", + Value: payload.Action, + Inline: true, + } + fields = append(fields, f) + } + if payload.ActionType != "" { + f := DiscordEmbedsFields{ + Name: "Action type", + Value: string(payload.ActionType), + Inline: true, + } + fields = append(fields, f) + } + if payload.ActionClient != "" { + f := DiscordEmbedsFields{ + Name: "Action client", + Value: payload.ActionClient, + Inline: true, + } + fields = append(fields, f) + } + if len(payload.Rejections) > 0 { + f := DiscordEmbedsFields{ + Name: "Reasons", + Value: fmt.Sprintf("```\n%v\n```", strings.Join(payload.Rejections, ", ")), + Inline: false, + } + fields = append(fields, f) + } + + embed := DiscordEmbeds{ + Title: payload.ReleaseName, + Description: "New release!", + Color: int(color), + Fields: fields, + Timestamp: time.Now(), + } + + if payload.Subject != "" && payload.Message != "" { + embed.Title = payload.Subject + embed.Description = payload.Message + } + + return embed } diff --git a/internal/notification/service.go b/internal/notification/service.go index 47bd41c..7113bce 100644 --- a/internal/notification/service.go +++ b/internal/notification/service.go @@ -2,13 +2,9 @@ package notification import ( "context" - "fmt" "github.com/autobrr/autobrr/internal/domain" "github.com/autobrr/autobrr/internal/logger" - - "github.com/containrrr/shoutrrr" - t "github.com/containrrr/shoutrrr/pkg/types" ) type Service interface { @@ -17,20 +13,26 @@ type Service interface { Store(ctx context.Context, n domain.Notification) (*domain.Notification, error) Update(ctx context.Context, n domain.Notification) (*domain.Notification, error) Delete(ctx context.Context, id int) error - Send(event domain.NotificationEvent, msg string) error - SendEvent(event domain.EventsReleasePushed) error + Send(event domain.NotificationEvent, payload domain.NotificationPayload) error + Test(ctx context.Context, notification domain.Notification) error } type service struct { - log logger.Logger - repo domain.NotificationRepo + log logger.Logger + repo domain.NotificationRepo + senders []domain.NotificationSender } func NewService(log logger.Logger, repo domain.NotificationRepo) Service { - return &service{ - log: log, - repo: repo, + s := &service{ + log: log, + repo: repo, + senders: []domain.NotificationSender{}, } + + s.registerSenders() + + return s } func (s *service) Find(ctx context.Context, params domain.NotificationQueryParams) ([]domain.Notification, int, error) { @@ -42,109 +44,96 @@ func (s *service) FindByID(ctx context.Context, id int) (*domain.Notification, e } func (s *service) Store(ctx context.Context, n domain.Notification) (*domain.Notification, error) { - return s.repo.Store(ctx, n) + _, err := s.repo.Store(ctx, n) + if err != nil { + return nil, err + } + + // reset senders + s.senders = []domain.NotificationSender{} + + // re register senders + s.registerSenders() + + return nil, nil } func (s *service) Update(ctx context.Context, n domain.Notification) (*domain.Notification, error) { - return s.repo.Update(ctx, n) + _, err := s.repo.Update(ctx, n) + if err != nil { + return nil, err + } + + // reset senders + s.senders = []domain.NotificationSender{} + + // re register senders + s.registerSenders() + + return nil, nil } func (s *service) Delete(ctx context.Context, id int) error { - return s.repo.Delete(ctx, id) + err := s.repo.Delete(ctx, id) + if err != nil { + return err + } + + // reset senders + s.senders = []domain.NotificationSender{} + + // re register senders + s.registerSenders() + + return nil +} + +func (s *service) registerSenders() { + senders, err := s.repo.List(context.Background()) + if err != nil { + return + } + + for _, n := range senders { + if n.Enabled { + switch n.Type { + case domain.NotificationTypeDiscord: + s.senders = append(s.senders, NewDiscordSender(s.log, n)) + case domain.NotificationTypeTelegram: + s.senders = append(s.senders, NewTelegramSender(s.log, n)) + } + } + } + + return } // Send notifications -func (s *service) Send(event domain.NotificationEvent, msg string) error { - // find notifications for type X +func (s *service) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + s.log.Debug().Msgf("sending notification for %v", string(event)) - notifications, err := s.repo.List(context.Background()) - if err != nil { - return err - } - - var urls []string - - for _, n := range notifications { - if !n.Enabled { - continue - } - - switch n.Type { - case domain.NotificationTypeDiscord: - urls = append(urls, fmt.Sprintf("discord://%v@%v", n.Token, n.Webhook)) - default: - return nil + for _, sender := range s.senders { + // check if sender is active and have notification types + if sender.CanSend(event) { + sender.Send(event, payload) } } - if len(urls) == 0 { - return nil - } - - sender, err := shoutrrr.CreateSender(urls...) - if err != nil { - return err - } - - p := t.Params{"title": "TEST"} - items := []t.MessageItem{ - { - Text: "text hello", - Fields: []t.Field{ - { - Key: "eventt", - Value: "push?", - }, - }, - }, - } - //items = append(items, t.MessageItem{ - // Text: "text hello", - // Fields: []t.Field{ - // { - // Key: "eventt", - // Value: "push?", - // }, - // }, - //}) - - sender.SendItems(items, p) - return nil } -func (s *service) SendEvent(event domain.EventsReleasePushed) error { - notifications, err := s.repo.List(context.Background()) - if err != nil { - return err +func (s *service) Test(ctx context.Context, notification domain.Notification) error { + var agent domain.NotificationSender + + switch notification.Type { + case domain.NotificationTypeDiscord: + agent = NewDiscordSender(s.log, notification) + case domain.NotificationTypeTelegram: + agent = NewTelegramSender(s.log, notification) } - return s.send(notifications, event) -} - -func (s *service) send(notifications []domain.Notification, event domain.EventsReleasePushed) error { - // find notifications for type X - for _, n := range notifications { - if !n.Enabled { - continue - } - - if n.Events == nil { - continue - } - - for _, evt := range n.Events { - if evt == string(event.Status) { - switch n.Type { - case domain.NotificationTypeDiscord: - go s.discordNotification(event, n.Webhook) - default: - return nil - } - } - } - - } - - return nil + return agent.Send(domain.NotificationEventTest, domain.NotificationPayload{ + Subject: "Test Notification", + Message: "autobrr goes brr!!", + }) } diff --git a/internal/notification/telegram.go b/internal/notification/telegram.go new file mode 100644 index 0000000..3ca142e --- /dev/null +++ b/internal/notification/telegram.go @@ -0,0 +1,147 @@ +package notification + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "fmt" + "html" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/autobrr/autobrr/internal/domain" + "github.com/autobrr/autobrr/internal/logger" +) + +type TelegramMessage struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` +} + +type telegramSender struct { + log logger.Logger + Settings domain.Notification +} + +func NewTelegramSender(log logger.Logger, settings domain.Notification) domain.NotificationSender { + return &telegramSender{ + log: log, + Settings: settings, + } +} + +func (s *telegramSender) Send(event domain.NotificationEvent, payload domain.NotificationPayload) error { + m := TelegramMessage{ + ChatID: s.Settings.Channel, + Text: s.buildMessage(event, payload), + ParseMode: "HTML", + //ParseMode: "MarkdownV2", + } + + jsonData, err := json.Marshal(m) + if err != nil { + s.log.Error().Err(err).Msgf("telegram client could not marshal data: %v", m) + return err + } + + url := fmt.Sprintf("https://api.telegram.org/bot%v/sendMessage", s.Settings.Token) + + req, err := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonData)) + if err != nil { + s.log.Error().Err(err).Msgf("telegram client request error: %v", event) + return err + } + + 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("telegram client request error: %v", event) + return err + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + s.log.Error().Err(err).Msgf("telegram client request error: %v", event) + return err + } + + defer res.Body.Close() + + s.log.Trace().Msgf("telegram status: %v response: %v", res.StatusCode, string(body)) + + if res.StatusCode != http.StatusOK { + s.log.Error().Err(err).Msgf("telegram client request error: %v", string(body)) + return fmt.Errorf("err: %v", string(body)) + } + + s.log.Debug().Msg("notification successfully sent to telegram") + return nil +} + +func (s *telegramSender) CanSend(event domain.NotificationEvent) bool { + if s.isEnabled() && s.isEnabledEvent(event) { + return true + } + return false +} + +func (s *telegramSender) isEnabled() bool { + if s.Settings.Enabled && s.Settings.Token != "" && s.Settings.Channel != "" { + return true + } + return false +} + +func (s *telegramSender) isEnabledEvent(event domain.NotificationEvent) bool { + for _, e := range s.Settings.Events { + if e == string(event) { + return true + } + } + + 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.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/api/APIClient.ts b/web/src/api/APIClient.ts index 783e6e3..f1338c4 100644 --- a/web/src/api/APIClient.ts +++ b/web/src/api/APIClient.ts @@ -135,7 +135,8 @@ export const APIClient = { getAll: () => appClient.Get("api/notification"), create: (notification: Notification) => appClient.Post("api/notification", notification), update: (notification: Notification) => appClient.Put(`api/notification/${notification.id}`, notification), - delete: (id: number) => appClient.Delete(`api/notification/${id}`) + delete: (id: number) => appClient.Delete(`api/notification/${id}`), + test: (n: Notification) => appClient.Post("api/notification/test", n) }, release: { find: (query?: string) => appClient.Get(`api/release${query}`), diff --git a/web/src/components/panels/index.tsx b/web/src/components/panels/index.tsx index ca6a879..3d35042 100644 --- a/web/src/components/panels/index.tsx +++ b/web/src/components/panels/index.tsx @@ -17,6 +17,7 @@ interface SlideOverProps { children?: (values: DataType) => React.ReactNode; deleteAction?: () => void; type: "CREATE" | "UPDATE"; + testFn?: (data: unknown) => void; } function SlideOver({ @@ -28,11 +29,18 @@ function SlideOver({ isOpen, toggle, type, - children + children, + testFn }: SlideOverProps): React.ReactElement { const cancelModalButtonRef = useRef(null); const [deleteModalIsOpen, toggleDeleteModal] = useToggle(false); + const test = (values: unknown) => { + if (testFn) { + testFn(values); + } + }; + return ( @@ -106,17 +114,26 @@ function SlideOver({ className="inline-flex items-center justify-center px-4 py-2 border border-transparent font-medium rounded-md text-red-700 dark:text-white bg-red-100 dark:bg-red-700 hover:bg-red-200 dark:hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 sm:text-sm" onClick={toggleDeleteModal} > - Remove + Remove )}
+ {testFn && ( + + )}
@@ -42,10 +43,10 @@ function NotificationSettings() {
  1. -
    Enabled
    -
    Name
    +
    Enabled
    +
    Name
    Type
    -
    Events
    +
    Events
  2. {data && data.map((n: Notification) => ( @@ -59,6 +60,29 @@ function NotificationSettings() { ); } + +const DiscordIcon = () => ( + + + +); + +const TelegramIcon = () => ( + + + +); + + +const iconComponentMap: componentMapType = { + DISCORD: Discord, + TELEGRAM: Telegram +}; + interface ListItemProps { notification: Notification; } @@ -70,8 +94,8 @@ function ListItem({ notification }: ListItemProps) {
  3. -
    -
    +
    +
    -
    +
    {notification.name}
    -
    - {notification.type} +
    + {iconComponentMap[notification.type]}
    -
    - {notification.events.map((n, idx) => ( - - {n} - - ))} +
    + + {notification.events.length} +
    -
    +
    - Edit + Edit
    diff --git a/web/src/types/Notification.d.ts b/web/src/types/Notification.d.ts index baca81b..4703ab2 100644 --- a/web/src/types/Notification.d.ts +++ b/web/src/types/Notification.d.ts @@ -1,4 +1,4 @@ -type NotificationType = "DISCORD"; +type NotificationType = "DISCORD" | "TELEGRAM"; interface Notification { id: number; @@ -6,5 +6,7 @@ interface Notification { enabled: boolean; type: NotificationType; events: string[]; - webhook: string; + webhook?: string; + token?: string; + channel?: string; } \ No newline at end of file