From ee5d0181fc9a673b27aefd1d09b0a78c3d2e4f55 Mon Sep 17 00:00:00 2001 From: Jakob Ankarhem Date: Tue, 5 Jan 2021 05:19:25 +0100 Subject: [PATCH] feat(notifications): add pushover integration (#574) * feat(notifications): add pushover integration * refactor(pushover): group i18n translations --- overseerr-api.yml | 66 +++++ server/index.ts | 2 + server/lib/notifications/agents/pushover.ts | 122 +++++++++ server/lib/settings.ts | 20 ++ server/routes/settings.ts | 35 +++ src/assets/extlogos/pushover.svg | 6 + .../NotificationsPushover/index.tsx | 257 ++++++++++++++++++ .../Settings/SettingsNotifications.tsx | 12 + src/i18n/locale/en.json | 21 ++ src/pages/settings/notifications/pushover.tsx | 17 ++ 10 files changed, 558 insertions(+) create mode 100644 server/lib/notifications/agents/pushover.ts create mode 100644 src/assets/extlogos/pushover.svg create mode 100644 src/components/Settings/Notifications/NotificationsPushover/index.tsx create mode 100644 src/pages/settings/notifications/pushover.tsx diff --git a/overseerr-api.yml b/overseerr-api.yml index dccd60ba6..3dd8d31ee 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -871,6 +871,26 @@ components: type: string chatId: type: string + PushoverSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + accessToken: + type: string + userToken: + type: string + priority: + type: number + sound: + type: string NotificationEmailSettings: type: object properties: @@ -1720,6 +1740,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/pushover: + get: + summary: Return current pushover notification settings + description: Returns current pushover notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned pushover settings + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + post: + summary: Update pushover notification settings + description: Update current pushover notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + /settings/notifications/pushover/test: + post: + summary: Test the provided pushover settings + description: Sends a test notification to the pushover agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/PushoverSettings' + responses: + '204': + description: Test notification attempted /settings/notifications/slack: get: summary: Return current slack notification settings diff --git a/server/index.ts b/server/index.ts index eaf36833e..32b144474 100644 --- a/server/index.ts +++ b/server/index.ts @@ -20,6 +20,7 @@ import EmailAgent from './lib/notifications/agents/email'; import TelegramAgent from './lib/notifications/agents/telegram'; import { getAppVersion } from './utils/appVersion'; import SlackAgent from './lib/notifications/agents/slack'; +import PushoverAgent from './lib/notifications/agents/pushover'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -49,6 +50,7 @@ app new EmailAgent(), new SlackAgent(), new TelegramAgent(), + new PushoverAgent(), ]); // Start Jobs diff --git a/server/lib/notifications/agents/pushover.ts b/server/lib/notifications/agents/pushover.ts new file mode 100644 index 000000000..072352ab7 --- /dev/null +++ b/server/lib/notifications/agents/pushover.ts @@ -0,0 +1,122 @@ +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentPushover } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface PushoverPayload { + token: string; + user: string; + title: string; + message: string; + html: number; +} + +class PushoverAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentPushover { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.pushover; + } + + public shouldSend(type: Notification): boolean { + if ( + this.getSettings().enabled && + this.getSettings().options.accessToken && + this.getSettings().options.userToken && + hasNotificationType(type, this.getSettings().types) + ) { + return true; + } + + return false; + } + + private constructMessageDetails( + type: Notification, + payload: NotificationPayload + ): { title: string; message: string } { + const settings = getSettings(); + let messageTitle = ''; + let message = ''; + + const title = payload.subject; + const plot = payload.message; + const user = payload.notifyUser.username; + + switch (type) { + case Notification.MEDIA_PENDING: + messageTitle = 'New Request'; + message += `${title}\n\n`; + message += `${plot}\n\n`; + message += `Requested By\n${user}\n\n`; + message += `Status\nPending Approval\n`; + break; + case Notification.MEDIA_APPROVED: + messageTitle = 'Request Approved'; + message += `${title}\n\n`; + message += `${plot}\n\n`; + message += `Requested By\n${user}\n\n`; + message += `Status\nProcessing Request\n`; + break; + case Notification.MEDIA_AVAILABLE: + messageTitle = 'Now available!'; + message += `${title}\n\n`; + message += `${plot}\n\n`; + message += `Requested By\n${user}\n\n`; + message += `Status\nAvailable\n`; + break; + case Notification.TEST_NOTIFICATION: + messageTitle = 'Test Notification'; + message += `${title}\n\n`; + message += `${plot}\n\n`; + message += `Requested By\n${user}\n`; + break; + } + + if (settings.main.applicationUrl && payload.media) { + const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; + message += `Open in Overseerr`; + } + + return { title: messageTitle, message }; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending Pushover notification', { label: 'Notifications' }); + try { + const endpoint = 'https://api.pushover.net/1/messages.json'; + + const { accessToken, userToken } = this.getSettings().options; + + const { title, message } = this.constructMessageDetails(type, payload); + + await axios.post(endpoint, { + token: accessToken, + user: userToken, + title: title, + message: message, + html: 1, + } as PushoverPayload); + + return true; + } catch (e) { + logger.error('Error sending Pushover notification', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } +} + +export default PushoverAgent; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 75d3c72fc..cea7774a2 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -92,11 +92,21 @@ export interface NotificationAgentTelegram extends NotificationAgentConfig { }; } +export interface NotificationAgentPushover extends NotificationAgentConfig { + options: { + accessToken: string; + userToken: string; + priority: number; + sound: string; + }; +} + interface NotificationAgents { email: NotificationAgentEmail; discord: NotificationAgentDiscord; slack: NotificationAgentSlack; telegram: NotificationAgentTelegram; + pushover: NotificationAgentPushover; } interface NotificationSettings { @@ -174,6 +184,16 @@ class Settings { chatId: '', }, }, + pushover: { + enabled: false, + types: 0, + options: { + accessToken: '', + userToken: '', + priority: 0, + sound: '', + }, + }, }, }, }; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index ba9b91bc1..91d196e14 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -26,6 +26,7 @@ import DiscordAgent from '../lib/notifications/agents/discord'; import EmailAgent from '../lib/notifications/agents/email'; import SlackAgent from '../lib/notifications/agents/slack'; import TelegramAgent from '../lib/notifications/agents/telegram'; +import PushoverAgent from '../lib/notifications/agents/pushover'; const settingsRoutes = Router(); @@ -538,6 +539,40 @@ settingsRoutes.post('/notifications/telegram/test', (req, res, next) => { return res.status(204).send(); }); +settingsRoutes.get('/notifications/pushover', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +settingsRoutes.post('/notifications/pushover', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.pushover = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.pushover); +}); + +settingsRoutes.post('/notifications/pushover/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const pushoverAgent = new PushoverAgent(req.body); + pushoverAgent.send(Notification.TEST_NOTIFICATION, { + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }); + + return res.status(204).send(); +}); + settingsRoutes.get('/notifications/email', (_req, res) => { const settings = getSettings(); diff --git a/src/assets/extlogos/pushover.svg b/src/assets/extlogos/pushover.svg new file mode 100644 index 000000000..e3d7161f4 --- /dev/null +++ b/src/assets/extlogos/pushover.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/components/Settings/Notifications/NotificationsPushover/index.tsx b/src/components/Settings/Notifications/NotificationsPushover/index.tsx new file mode 100644 index 000000000..9d2c8d865 --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsPushover/index.tsx @@ -0,0 +1,257 @@ +import React from 'react'; +import { Field, Form, Formik } from 'formik'; +import useSWR from 'swr'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import Button from '../../../Common/Button'; +import { defineMessages, useIntl } from 'react-intl'; +import axios from 'axios'; +import * as Yup from 'yup'; +import { useToasts } from 'react-toast-notifications'; +import Alert from '../../../Common/Alert'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving...', + agentenabled: 'Agent Enabled', + accessToken: 'Access Token', + userToken: 'User Token', + validationAccessTokenRequired: 'You must provide an access token.', + validationUserTokenRequired: 'You must provide a user token.', + pushoversettingssaved: 'Pushover notification settings saved!', + pushoversettingsfailed: 'Pushover notification settings failed to save.', + testsent: 'Test notification sent!', + test: 'Test', + settinguppushover: 'Setting up Pushover Notifications', + settinguppushoverDescription: + 'To setup Pushover you need to register an application and get the access token.\ + When setting up the application you can use one of the icons in the public folder on github.\ + You also need the pushover user token which can be found on the start page when you log in.', + notificationtypes: 'Notification Types', +}); + +const NotificationsPushover: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/pushover' + ); + + const NotificationsPushoverSchema = Yup.object().shape({ + accessToken: Yup.string().required( + intl.formatMessage(messages.validationAccessTokenRequired) + ), + userToken: Yup.string().required( + intl.formatMessage(messages.validationUserTokenRequired) + ), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/pushover', { + enabled: values.enabled, + types: values.types, + options: { + accessToken: values.accessToken, + userToken: values.userToken, + }, + }); + addToast(intl.formatMessage(messages.pushoversettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.pushoversettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting, values, isValid, setFieldValue }) => { + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/pushover/test', { + enabled: true, + types: values.types, + options: { + accessToken: values.accessToken, + userToken: values.userToken, + }, + }); + + addToast(intl.formatMessage(messages.testsent), { + appearance: 'info', + autoDismiss: true, + }); + }; + + return ( + <> + + {intl.formatMessage(messages.settinguppushoverDescription, { + RegisterApplicationLink: function RegisterApplicationLink(msg) { + return ( + + {msg} + + ); + }, + IconLink: function IconLink(msg) { + return ( + + {msg} + + ); + }, + })} + +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.accessToken && touched.accessToken && ( +
+ {errors.accessToken} +
+ )} +
+ +
+
+ +
+ {errors.userToken && touched.userToken && ( +
{errors.userToken}
+ )} +
+
+
+
+
+
+
+ {intl.formatMessage(messages.notificationtypes)} +
+
+
+
+ + setFieldValue('types', newTypes) + } + /> +
+
+
+
+
+
+
+ + + + + + +
+
+
+ + ); + }} +
+ ); +}; + +export default NotificationsPushover; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 28af73f86..e873ecf8f 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -5,6 +5,7 @@ import { defineMessages, useIntl } from 'react-intl'; import DiscordLogo from '../../assets/extlogos/discord_white.svg'; import SlackLogo from '../../assets/extlogos/slack.svg'; import TelegramLogo from '../../assets/extlogos/telegram.svg'; +import PushoverLogo from '../../assets/extlogos/pushover.svg'; const messages = defineMessages({ notificationsettings: 'Notification Settings', @@ -77,6 +78,17 @@ const settingsRoutes: SettingsRoute[] = [ route: '/settings/notifications/telegram', regex: /^\/settings\/notifications\/telegram/, }, + { + text: 'Pushover', + content: ( + + + Pushover + + ), + route: '/settings/notifications/pushover', + regex: /^\/settings\/notifications\/pushover/, + }, ]; const SettingsNotifications: React.FC = ({ children }) => { diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 30c3af47d..2dc114955 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -83,13 +83,20 @@ "components.RequestList.RequestItem.notavailable": "N/A", "components.RequestList.RequestItem.requestedby": "Requested by {username}", "components.RequestList.RequestItem.seasons": "Seasons", + "components.RequestList.filterAll": "All", + "components.RequestList.filterApproved": "Approved", + "components.RequestList.filterPending": "Pending", "components.RequestList.mediaInfo": "Media Info", "components.RequestList.modifiedBy": "Last Modified By", "components.RequestList.next": "Next", + "components.RequestList.noresults": "No Results.", "components.RequestList.previous": "Previous", "components.RequestList.requestedAt": "Requested At", "components.RequestList.requests": "Requests", + "components.RequestList.showallrequests": "Show All Requests", "components.RequestList.showingresults": "Showing {from} to {to} of {total} results", + "components.RequestList.sortAdded": "Request Date", + "components.RequestList.sortModified": "Last Modified", "components.RequestList.status": "Status", "components.RequestModal.cancel": "Cancel Request", "components.RequestModal.cancelling": "Cancelling…", @@ -112,6 +119,20 @@ "components.RequestModal.selectseason": "Select season(s)", "components.RequestModal.status": "Status", "components.Search.searchresults": "Search Results", + "components.Settings.Notifications.NotificationsPushover.accessToken": "Access Token", + "components.Settings.Notifications.NotificationsPushover.agentenabled": "Agent Enabled", + "components.Settings.Notifications.NotificationsPushover.notificationtypes": "Notification Types", + "components.Settings.Notifications.NotificationsPushover.pushoversettingsfailed": "Pushover notification settings failed to save.", + "components.Settings.Notifications.NotificationsPushover.pushoversettingssaved": "Pushover notification settings saved!", + "components.Settings.Notifications.NotificationsPushover.save": "Save Changes", + "components.Settings.Notifications.NotificationsPushover.saving": "Saving...", + "components.Settings.Notifications.NotificationsPushover.settinguppushover": "Setting up Pushover Notifications", + "components.Settings.Notifications.NotificationsPushover.settinguppushoverDescription": "To setup Pushover you need to register an application and get the access token. When setting up the application you can use one of the icons in the public folder on github. You also need the pushover user token which can be found on the start page when you log in.", + "components.Settings.Notifications.NotificationsPushover.test": "Test", + "components.Settings.Notifications.NotificationsPushover.testsent": "Test notification sent!", + "components.Settings.Notifications.NotificationsPushover.userToken": "User Token", + "components.Settings.Notifications.NotificationsPushover.validationAccessTokenRequired": "You must provide an access token.", + "components.Settings.Notifications.NotificationsPushover.validationUserTokenRequired": "You must provide a user token.", "components.Settings.Notifications.NotificationsSlack.agentenabled": "Agent Enabled", "components.Settings.Notifications.NotificationsSlack.notificationtypes": "Notification Types", "components.Settings.Notifications.NotificationsSlack.save": "Save Changes", diff --git a/src/pages/settings/notifications/pushover.tsx b/src/pages/settings/notifications/pushover.tsx new file mode 100644 index 000000000..69b6f9451 --- /dev/null +++ b/src/pages/settings/notifications/pushover.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsPushover from '../../../components/Settings/Notifications/NotificationsPushover'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsPage;