From ca3c4bbd8ad86820e318d3942e8a159b015e6de2 Mon Sep 17 00:00:00 2001 From: jadencodes Date: Tue, 7 Feb 2023 23:14:03 -0700 Subject: [PATCH] feat(notifications): add apprise api agent Adds basic apprise api agent to receive notifications --- docs/using-overseerr/notifications/README.md | 1 + docs/using-overseerr/notifications/apprise.md | 7 + overseerr-api.yml | 61 +++++ server/index.ts | 2 + server/lib/notifications/agents/apprise.ts | 147 +++++++++++ server/lib/settings.ts | 15 ++ server/routes/settings/notifications.ts | 35 +++ src/assets/extlogos/apprise.svg | 1 + .../NotificationsApprise/index.tsx | 234 ++++++++++++++++++ .../Settings/SettingsNotifications.tsx | 12 + src/i18n/locale/en.json | 10 + src/pages/settings/notifications/apprise.tsx | 19 ++ 12 files changed, 544 insertions(+) create mode 100644 docs/using-overseerr/notifications/apprise.md create mode 100644 server/lib/notifications/agents/apprise.ts create mode 100644 src/assets/extlogos/apprise.svg create mode 100644 src/components/Settings/Notifications/NotificationsApprise/index.tsx create mode 100644 src/pages/settings/notifications/apprise.tsx diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index 2bff1388..85d03460 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -6,6 +6,7 @@ Overseerr currently supports the following notification agents: - [Email](./email.md) - [Web Push](./webpush.md) +- [Apprise](./apprise.md) - [Discord](./discord.md) - [Gotify](./gotify.md) - [LunaSea](./lunasea.md) diff --git a/docs/using-overseerr/notifications/apprise.md b/docs/using-overseerr/notifications/apprise.md new file mode 100644 index 00000000..16e5907d --- /dev/null +++ b/docs/using-overseerr/notifications/apprise.md @@ -0,0 +1,7 @@ +# Apprise + +## Configuration + +### Server URL + +Set this to the URL of your Apprise server and configuration key. Usually something like http://localhost:8000/notify/apprise. diff --git a/overseerr-api.yml b/overseerr-api.yml index 443a9c94..630c6868 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1347,6 +1347,21 @@ components: allowSelfSigned: type: boolean example: false + AppriseSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + url: + type: string + example: http://localhost:8000/notify/apprise Job: type: object properties: @@ -3091,6 +3106,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/apprise: + get: + summary: Get Apprise notification settings + description: Returns current Apprise notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Apprise settings + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + post: + summary: Update Apprise notification settings + description: Update Apprise notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + /settings/notifications/apprise/test: + post: + summary: Test Apprise settings + description: Sends a test notification to the Apprise agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AppriseSettings' + responses: + '204': + description: Test notification attempted /settings/discover: get: summary: Get all discover sliders diff --git a/server/index.ts b/server/index.ts index 93703402..6bdfa39a 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { Session } from '@server/entity/Session'; import { User } from '@server/entity/User'; import { startJobs } from '@server/job/schedule'; import notificationManager from '@server/lib/notifications'; +import AppriseAgent from '@server/lib/notifications/agents/apprise'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; @@ -81,6 +82,7 @@ app // Register Notification Agents notificationManager.registerAgents([ + new AppriseAgent(), new DiscordAgent(), new EmailAgent(), new GotifyAgent(), diff --git a/server/lib/notifications/agents/apprise.ts b/server/lib/notifications/agents/apprise.ts new file mode 100644 index 00000000..d7aa85d2 --- /dev/null +++ b/server/lib/notifications/agents/apprise.ts @@ -0,0 +1,147 @@ +import { IssueStatus, IssueTypeName } from '@server/constants/issue'; +import type { NotificationAgentApprise } from '@server/lib/settings'; +import { getSettings } from '@server/lib/settings'; +import logger from '@server/logger'; +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import type { NotificationAgent, NotificationPayload } from './agent'; +import { BaseAgent } from './agent'; + +interface ApprisePayload { + title: string; + body: string; + type: string; +} + +class AppriseAgent + extends BaseAgent + implements NotificationAgent +{ + protected getSettings(): NotificationAgentApprise { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.apprise; + } + + public shouldSend(): boolean { + const settings = this.getSettings(); + + if (settings.enabled && settings.options.url) { + return true; + } + + return false; + } + + private getNotificationPayload( + type: Notification, + payload: NotificationPayload + ): ApprisePayload { + const { applicationUrl, applicationTitle } = getSettings().main; + let notificationType = 'info'; + + const title = payload.event + ? `${payload.event} - ${payload.subject}` + : payload.subject; + let body = payload.message ?? ''; + + if (payload.request) { + body += `\n\nRequested By: ${payload.request.requestedBy.displayName}`; + + let status = ''; + switch (type) { + case Notification.MEDIA_PENDING: + status = 'Pending Approval'; + break; + case Notification.MEDIA_APPROVED: + case Notification.MEDIA_AUTO_APPROVED: + status = 'Processing'; + break; + case Notification.MEDIA_AVAILABLE: + status = 'Available'; + break; + case Notification.MEDIA_DECLINED: + status = 'Declined'; + break; + case Notification.MEDIA_FAILED: + status = 'Failed'; + break; + } + + if (status) { + body += `\nRequest Status: ${status}`; + } + } else if (payload.comment) { + body += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; + } else if (payload.issue) { + body += `\n\nReported By: ${payload.issue.createdBy.displayName}`; + body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; + body += `\nIssue Status: ${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`; + + if (type == Notification.ISSUE_CREATED) { + notificationType = 'failure'; + } + } + + for (const extra of payload.extra ?? []) { + body += `\n\n**${extra.name}**\n${extra.value}`; + } + + if (applicationUrl && payload.media) { + const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; + body += `\n\nOpen in ${applicationTitle}(${actionUrl})`; + } + + return { + title, + body, + type: notificationType, + }; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + const settings = this.getSettings(); + + if ( + !payload.notifySystem || + !hasNotificationType(type, settings.types ?? 0) + ) { + return true; + } + + logger.debug('Sending Apprise notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + try { + const endpoint = `${settings.options.url}`; + const notificationPayload = this.getNotificationPayload(type, payload); + + await axios.post(endpoint, notificationPayload); + + return true; + } catch (e) { + logger.error('Error sending Apprise notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } +} + +export default AppriseAgent; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index cf475554..abebb55d 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -207,7 +207,14 @@ export interface NotificationAgentGotify extends NotificationAgentConfig { }; } +export interface NotificationAgentApprise extends NotificationAgentConfig { + options: { + url: string; + }; +} + export enum NotificationAgentKey { + APPRISE = 'apprise', DISCORD = 'discord', EMAIL = 'email', GOTIFY = 'gotify', @@ -220,6 +227,7 @@ export enum NotificationAgentKey { } interface NotificationAgents { + apprise: NotificationAgentApprise; discord: NotificationAgentDiscord; email: NotificationAgentEmail; gotify: NotificationAgentGotify; @@ -391,6 +399,13 @@ class Settings { token: '', }, }, + apprise: { + enabled: false, + types: 0, + options: { + url: '', + }, + }, }, }, jobs: { diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index 5a38555c..e8de0cf8 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -1,6 +1,7 @@ import type { User } from '@server/entity/User'; import { Notification } from '@server/lib/notifications'; import type { NotificationAgent } from '@server/lib/notifications/agents/agent'; +import AppriseAgent from '@server/lib/notifications/agents/apprise'; import DiscordAgent from '@server/lib/notifications/agents/discord'; import EmailAgent from '@server/lib/notifications/agents/email'; import GotifyAgent from '@server/lib/notifications/agents/gotify'; @@ -413,4 +414,38 @@ notificationRoutes.post('/gotify/test', async (req, res, next) => { } }); +notificationRoutes.get('/apprise', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.apprise); +}); + +notificationRoutes.post('/apprise', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.apprise = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.apprise); +}); + +notificationRoutes.post('/apprise/test', async (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information is missing from the request.', + }); + } + + const appriseAgent = new AppriseAgent(req.body); + if (await sendTestNotification(appriseAgent, req.user)) { + return res.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Apprise notification.', + }); + } +}); + export default notificationRoutes; diff --git a/src/assets/extlogos/apprise.svg b/src/assets/extlogos/apprise.svg new file mode 100644 index 00000000..b3b74d3e --- /dev/null +++ b/src/assets/extlogos/apprise.svg @@ -0,0 +1 @@ + diff --git a/src/components/Settings/Notifications/NotificationsApprise/index.tsx b/src/components/Settings/Notifications/NotificationsApprise/index.tsx new file mode 100644 index 00000000..a701328e --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsApprise/index.tsx @@ -0,0 +1,234 @@ +import Button from '@app/components/Common/Button'; +import LoadingSpinner from '@app/components/Common/LoadingSpinner'; +import NotificationTypeSelector from '@app/components/NotificationTypeSelector'; +import globalMessages from '@app/i18n/globalMessages'; +import { ArrowDownOnSquareIcon, BeakerIcon } from '@heroicons/react/24/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; + +const messages = defineMessages({ + agentenabled: 'Enable Agent', + url: 'Server URL', + validationUrlRequired: 'You must provide a valid URL', + validationUrlTrailingSlash: 'URL must not end in a trailing slash', + apprisesettingssaved: 'Apprise notification settings saved successfully!', + apprisesettingsfailed: 'Apprise notification settings failed to save.', + toastAppriseTestSending: 'Sending Apprise test notification…', + toastAppriseTestSuccess: 'Apprise test notification sent!', + toastAppriseTestFailed: 'Apprise test notification failed to send.', + validationTypes: 'You must select at least one notification type', +}); + +const NotificationsApprise = () => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const { + data, + error, + mutate: revalidate, + } = useSWR('/api/v1/settings/notifications/apprise'); + + const NotificationsAppriseSchema = Yup.object().shape({ + url: Yup.string() + .when('enabled', { + is: true, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationUrlRequired)), + otherwise: Yup.string().nullable(), + }) + .matches( + // eslint-disable-next-line no-useless-escape + /^(https?:)?\/\/(((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:)*@)?(((\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5])\.(\d|[1-9]\d|1\d\d|2[0-4]\d|25[0-5]))|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*)?([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))(:\d*)?)(\/((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)+(\/(([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)*)*)?)?(\?((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|[\uE000-\uF8FF]|\/|\?)*)?(\#((([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(%[\da-f]{2})|[!\$&'\(\)\*\+,;=]|:|@)|\/|\?)*)?$/i, + intl.formatMessage(messages.validationUrlRequired) + ) + .test( + 'no-trailing-slash', + intl.formatMessage(messages.validationUrlTrailingSlash), + (value) => !value || !value.endsWith('/') + ), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/apprise', { + enabled: values.enabled, + types: values.types, + options: { + url: values.url, + }, + }); + addToast(intl.formatMessage(messages.apprisesettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.apprisesettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ + errors, + touched, + isSubmitting, + values, + isValid, + setFieldValue, + setFieldTouched, + }) => { + const testSettings = async () => { + setIsTesting(true); + let toastId: string | undefined; + try { + addToast( + intl.formatMessage(messages.toastAppriseTestSending), + { + autoDsmiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/apprise/test', { + enabled: true, + types: values.types, + options: { + url: values.url, + }, + }); + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastAppriseTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastAppriseTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.url && + touched.url && + typeof errors.url === 'string' && ( +
{errors.url}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + + if (newTypes) { + setFieldValue('enabled', true); + } + }} + error={ + values.enabled && !values.types && touched.types + ? intl.formatMessage(messages.validationTypes) + : undefined + } + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ ); +}; + +export default NotificationsApprise; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 0523e150..230c9598 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -1,3 +1,4 @@ +import AppriseLogo from '@app/assets/extlogos/apprise.svg'; import DiscordLogo from '@app/assets/extlogos/discord.svg'; import GotifyLogo from '@app/assets/extlogos/gotify.svg'; import LunaSeaLogo from '@app/assets/extlogos/lunasea.svg'; @@ -129,6 +130,17 @@ const SettingsNotifications = ({ children }: SettingsNotificationsProps) => { route: '/settings/notifications/telegram', regex: /^\/settings\/notifications\/telegram/, }, + { + text: 'Apprise', + content: ( + + + Apprise + + ), + route: '/settings/notifications/apprise', + regex: /^\/settings\/notifications\/apprise/, + }, { text: intl.formatMessage(messages.webhook), content: ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 72d58ca5..3cbdfcca 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -515,6 +515,16 @@ "components.Selector.showless": "Show Less", "components.Selector.showmore": "Show More", "components.Selector.starttyping": "Starting typing to search.", + "components.Settings.Notifications.NotificationsApprise.agentenabled": "Enable Agent", + "components.Settings.Notifications.NotificationsApprise.apprisesettingsfailed": "Apprise notification settings failed to save.", + "components.Settings.Notifications.NotificationsApprise.apprisesettingssaved": "Apprise notification settings saved successfully!", + "components.Settings.Notifications.NotificationsApprise.toastAppriseTestFailed": "Apprise test notification failed to send.", + "components.Settings.Notifications.NotificationsApprise.toastAppriseTestSending": "Sending Apprise test notification…", + "components.Settings.Notifications.NotificationsApprise.toastAppriseTestSuccess": "Apprise test notification sent!", + "components.Settings.Notifications.NotificationsApprise.url": "Server URL", + "components.Settings.Notifications.NotificationsApprise.validationTypes": "You must select at least one notification type", + "components.Settings.Notifications.NotificationsApprise.validationUrlRequired": "You must provide a valid URL", + "components.Settings.Notifications.NotificationsApprise.validationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.Notifications.NotificationsGotify.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsGotify.gotifysettingsfailed": "Gotify notification settings failed to save.", "components.Settings.Notifications.NotificationsGotify.gotifysettingssaved": "Gotify notification settings saved successfully!", diff --git a/src/pages/settings/notifications/apprise.tsx b/src/pages/settings/notifications/apprise.tsx new file mode 100644 index 00000000..2ab65a42 --- /dev/null +++ b/src/pages/settings/notifications/apprise.tsx @@ -0,0 +1,19 @@ +import NotificationsApprise from '@app/components/Settings/Notifications/NotificationsApprise'; +import SettingsLayout from '@app/components/Settings/SettingsLayout'; +import SettingsNotifications from '@app/components/Settings/SettingsNotifications'; +import useRouteGuard from '@app/hooks/useRouteGuard'; +import { Permission } from '@app/hooks/useUser'; +import type { NextPage } from 'next'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.ADMIN); + return ( + + + + + + ); +}; + +export default NotificationsPage;