diff --git a/docs/using-overseerr/notifications/README.md b/docs/using-overseerr/notifications/README.md index c894b0b2e..2bff13886 100644 --- a/docs/using-overseerr/notifications/README.md +++ b/docs/using-overseerr/notifications/README.md @@ -7,6 +7,7 @@ Overseerr currently supports the following notification agents: - [Email](./email.md) - [Web Push](./webpush.md) - [Discord](./discord.md) +- [Gotify](./gotify.md) - [LunaSea](./lunasea.md) - [Pushbullet](./pushbullet.md) - [Pushover](./pushover.md) diff --git a/docs/using-overseerr/notifications/gotify.md b/docs/using-overseerr/notifications/gotify.md new file mode 100644 index 000000000..16e7cd599 --- /dev/null +++ b/docs/using-overseerr/notifications/gotify.md @@ -0,0 +1,15 @@ +# Gotify + +## Configuration + +### Server URL + +Set this to the URL of your Gotify server. + +### Application Token + +Add an application to your Gotify server, and set this field to the generated application token. + +{% hint style="info" %} +Please refer to the [Gotify API documentation](https://gotify.net/docs) for more details on configuring these notifications. +{% endhint %} diff --git a/overseerr-api.yml b/overseerr-api.yml index 213ef1d70..211f029cd 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -1231,6 +1231,22 @@ components: type: string userToken: type: string + GotifySettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + url: + type: string + token: + type: string LunaSeaSettings: type: object properties: @@ -2681,6 +2697,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/gotify: + get: + summary: Get Gotify notification settings + description: Returns current Gotify notification settings in a JSON object. + tags: + - settings + responses: + '200': + description: Returned Gotify settings + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + post: + summary: Update Gotify notification settings + description: Update Gotify notification settings with the provided values. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + /settings/notifications/gotify/test: + post: + summary: Test Gotify settings + description: Sends a test notification to the Gotify agent. + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/GotifySettings' + responses: + '204': + description: Test notification attempted /settings/notifications/slack: get: summary: Get Slack notification settings diff --git a/server/index.ts b/server/index.ts index 24c007f2a..c80530120 100644 --- a/server/index.ts +++ b/server/index.ts @@ -17,6 +17,7 @@ import { startJobs } from './job/schedule'; import notificationManager from './lib/notifications'; import DiscordAgent from './lib/notifications/agents/discord'; import EmailAgent from './lib/notifications/agents/email'; +import GotifyAgent from './lib/notifications/agents/gotify'; import LunaSeaAgent from './lib/notifications/agents/lunasea'; import PushbulletAgent from './lib/notifications/agents/pushbullet'; import PushoverAgent from './lib/notifications/agents/pushover'; @@ -76,6 +77,7 @@ app notificationManager.registerAgents([ new DiscordAgent(), new EmailAgent(), + new GotifyAgent(), new LunaSeaAgent(), new PushbulletAgent(), new PushoverAgent(), diff --git a/server/lib/notifications/agents/gotify.ts b/server/lib/notifications/agents/gotify.ts new file mode 100644 index 000000000..ecd54ce75 --- /dev/null +++ b/server/lib/notifications/agents/gotify.ts @@ -0,0 +1,148 @@ +import axios from 'axios'; +import { hasNotificationType, Notification } from '..'; +import { IssueStatus, IssueTypeName } from '../../../constants/issue'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentGotify } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface GotifyPayload { + title: string; + message: string; + priority: number; + extras: any; +} + +class GotifyAgent + extends BaseAgent + implements NotificationAgent +{ + protected getSettings(): NotificationAgentGotify { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.gotify; + } + + public shouldSend(): boolean { + const settings = this.getSettings(); + + if (settings.enabled && settings.options.url && settings.options.token) { + return true; + } + + return false; + } + + private getNotificationPayload( + type: Notification, + payload: NotificationPayload + ): GotifyPayload { + const { applicationUrl, applicationTitle } = getSettings().main; + let priority = 0; + + const title = payload.event + ? `${payload.event} - ${payload.subject}` + : payload.subject; + let message = payload.message ?? ''; + + if (payload.request) { + message += `\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) { + message += `\nRequest Status: ${status}`; + } + } else if (payload.comment) { + message += `\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`; + } else if (payload.issue) { + message += `\n\nReported By: ${payload.issue.createdBy.displayName}`; + message += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`; + message += `\nIssue Status: ${ + payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved' + }`; + + if (type == Notification.ISSUE_CREATED) { + priority = 1; + } + } + + for (const extra of payload.extra ?? []) { + message += `\n\n**${extra.name}**\n${extra.value}`; + } + + if (applicationUrl && payload.media) { + const actionUrl = `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; + message += `\n\nOpen in ${applicationTitle}(${actionUrl})`; + } + + return { + extras: { + 'client::display': { + contentType: 'text/markdown', + }, + }, + title, + message, + priority, + }; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + const settings = this.getSettings(); + + if (!hasNotificationType(type, settings.types ?? 0)) { + return true; + } + + logger.debug('Sending Gotify notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + }); + try { + const endpoint = `${settings.options.url}/message?token=${settings.options.token}`; + const notificationPayload = this.getNotificationPayload(type, payload); + + await axios.post(endpoint, notificationPayload); + + return true; + } catch (e) { + logger.error('Error sending Gotify notification', { + label: 'Notifications', + type: Notification[type], + subject: payload.subject, + errorMessage: e.message, + response: e.response?.data, + }); + + return false; + } + } +} + +export default GotifyAgent; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index eb50e25bd..74d13e538 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -189,9 +189,17 @@ export interface NotificationAgentWebhook extends NotificationAgentConfig { }; } +export interface NotificationAgentGotify extends NotificationAgentConfig { + options: { + url: string; + token: string; + }; +} + export enum NotificationAgentKey { DISCORD = 'discord', EMAIL = 'email', + GOTIFY = 'gotify', PUSHBULLET = 'pushbullet', PUSHOVER = 'pushover', SLACK = 'slack', @@ -203,6 +211,7 @@ export enum NotificationAgentKey { interface NotificationAgents { discord: NotificationAgentDiscord; email: NotificationAgentEmail; + gotify: NotificationAgentGotify; lunasea: NotificationAgentLunaSea; pushbullet: NotificationAgentPushbullet; pushover: NotificationAgentPushover; @@ -359,6 +368,14 @@ class Settings { enabled: false, options: {}, }, + gotify: { + enabled: false, + types: 0, + options: { + url: '', + token: '', + }, + }, }, }, jobs: { diff --git a/server/routes/settings/notifications.ts b/server/routes/settings/notifications.ts index d98debb7a..5a337237d 100644 --- a/server/routes/settings/notifications.ts +++ b/server/routes/settings/notifications.ts @@ -4,6 +4,7 @@ import { Notification } from '../../lib/notifications'; import { NotificationAgent } from '../../lib/notifications/agents/agent'; import DiscordAgent from '../../lib/notifications/agents/discord'; import EmailAgent from '../../lib/notifications/agents/email'; +import GotifyAgent from '../../lib/notifications/agents/gotify'; import LunaSeaAgent from '../../lib/notifications/agents/lunasea'; import PushbulletAgent from '../../lib/notifications/agents/pushbullet'; import PushoverAgent from '../../lib/notifications/agents/pushover'; @@ -377,4 +378,46 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => { } }); +notificationRoutes.get('/gotify', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.gotify); +}); + +notificationRoutes.post('/gotify', (req, rest) => { + const settings = getSettings(); + + settings.notifications.agents.gotify = req.body; + settings.save(); + + rest.status(200).json(settings.notifications.agents.gotify); +}); + +notificationRoutes.post('/gotify/test', async (req, rest, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information is missing from request', + }); + } + + const gotifyAgent = new GotifyAgent(req.body); + if ( + await gotifyAgent.send(Notification.TEST_NOTIFICATION, { + notifyAdmin: false, + notifyUser: req.user, + subject: 'Test Notification', + message: + 'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?', + }) + ) { + return rest.status(204).send(); + } else { + return next({ + status: 500, + message: 'Failed to send Gotify notification.', + }); + } +}); + export default notificationRoutes; diff --git a/src/assets/extlogos/gotify.svg b/src/assets/extlogos/gotify.svg new file mode 100644 index 000000000..6d0789924 --- /dev/null +++ b/src/assets/extlogos/gotify.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/Settings/Notifications/NotificationsGotify/index.tsx b/src/components/Settings/Notifications/NotificationsGotify/index.tsx new file mode 100644 index 000000000..214cf9736 --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsGotify/index.tsx @@ -0,0 +1,256 @@ +import { BeakerIcon, SaveIcon } from '@heroicons/react/solid'; +import axios from 'axios'; +import { Field, Form, Formik } from 'formik'; +import React, { useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { useToasts } from 'react-toast-notifications'; +import useSWR from 'swr'; +import * as Yup from 'yup'; +import globalMessages from '../../../../i18n/globalMessages'; +import Button from '../../../Common/Button'; +import LoadingSpinner from '../../../Common/LoadingSpinner'; +import NotificationTypeSelector from '../../../NotificationTypeSelector'; + +const messages = defineMessages({ + agentenabled: 'Enable Agent', + url: 'Server URL', + token: 'Application Token', + validationUrlRequired: 'You must provide a valid URL', + validationUrlTrailingSlash: 'URL must not end in a trailing slash', + validationTokenRequired: 'You must provide an application token', + gotifysettingssaved: 'Gotify notification settings saved successfully!', + gotifysettingsfailed: 'Gotify notification settings failed to save.', + toastGotifyTestSending: 'Sending Gotify test notification…', + toastGotifyTestSuccess: 'Gotify test notification sent!', + toastGotifyTestFailed: 'Gotify test notification failed to send.', + validationTypes: 'You must select at least one notification type', +}); + +const NotificationsGotify: React.FC = () => { + const intl = useIntl(); + const { addToast, removeToast } = useToasts(); + const [isTesting, setIsTesting] = useState(false); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/gotify' + ); + + const NotificationsGotifySchema = 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('/') + ), + token: Yup.string().when('enabled', { + is: true, + then: Yup.string() + .nullable() + .required(intl.formatMessage(messages.validationTokenRequired)), + otherwise: Yup.string().nullable(), + }), + }); + + if (!data && !error) { + return ; + } + + return ( + { + try { + await axios.post('/api/v1/settings/notifications/gotify', { + enabled: values.enabled, + types: values.types, + options: { + url: values.url, + token: values.token, + }, + }); + addToast(intl.formatMessage(messages.gotifysettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.gotifysettingsfailed), { + 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.toastGotifyTestSending), + { + autoDsmiss: false, + appearance: 'info', + }, + (id) => { + toastId = id; + } + ); + await axios.post('/api/v1/settings/notifications/gotify/test', { + enabled: true, + types: values.types, + options: { + url: values.url, + token: values.token, + }, + }); + + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastGotifyTestSuccess), { + autoDismiss: true, + appearance: 'success', + }); + } catch (e) { + if (toastId) { + removeToast(toastId); + } + addToast(intl.formatMessage(messages.toastGotifyTestFailed), { + autoDismiss: true, + appearance: 'error', + }); + } finally { + setIsTesting(false); + } + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.url && touched.url && ( +
{errors.url}
+ )} +
+
+
+ +
+
+ +
+ {errors.token && touched.token && ( +
{errors.token}
+ )} +
+
+ { + setFieldValue('types', newTypes); + setFieldTouched('types'); + + if (newTypes) { + setFieldValue('enabled', true); + } + }} + error={ + values.enabled && !values.types && touched.types + ? intl.formatMessage(messages.validationTypes) + : undefined + } + /> +
+
+ + + + + + +
+
+ + ); + }} +
+ ); +}; + +export default NotificationsGotify; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 329ec679f..fda48aeae 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -2,6 +2,7 @@ import { CloudIcon, LightningBoltIcon, MailIcon } from '@heroicons/react/solid'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; import DiscordLogo from '../../assets/extlogos/discord.svg'; +import GotifyLogo from '../../assets/extlogos/gotify.svg'; import LunaSeaLogo from '../../assets/extlogos/lunasea.svg'; import PushbulletLogo from '../../assets/extlogos/pushbullet.svg'; import PushoverLogo from '../../assets/extlogos/pushover.svg'; @@ -58,6 +59,17 @@ const SettingsNotifications: React.FC = ({ children }) => { route: '/settings/notifications/discord', regex: /^\/settings\/notifications\/discord/, }, + { + text: 'Gotify', + content: ( + + + Gotify + + ), + route: '/settings/notifications/gotify', + regex: /^\/settings\/notifications\/gotify/, + }, { text: 'LunaSea', content: ( diff --git a/src/i18n/locale/en.json b/src/i18n/locale/en.json index 47f5f1c84..66f858953 100644 --- a/src/i18n/locale/en.json +++ b/src/i18n/locale/en.json @@ -365,6 +365,18 @@ "components.ResetPassword.validationpasswordrequired": "You must provide a password", "components.Search.search": "Search", "components.Search.searchresults": "Search Results", + "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!", + "components.Settings.Notifications.NotificationsGotify.toastGotifyTestFailed": "Gotify test notification failed to send.", + "components.Settings.Notifications.NotificationsGotify.toastGotifyTestSending": "Sending Gotify test notification…", + "components.Settings.Notifications.NotificationsGotify.toastGotifyTestSuccess": "Gotify test notification sent!", + "components.Settings.Notifications.NotificationsGotify.token": "Application Token", + "components.Settings.Notifications.NotificationsGotify.url": "Server URL", + "components.Settings.Notifications.NotificationsGotify.validationTokenRequired": "You must provide a valid application token", + "components.Settings.Notifications.NotificationsGotify.validationTypes": "You must select at least one notification type", + "components.Settings.Notifications.NotificationsGotify.validationUrlRequired": "You must provide a valid URL", + "components.Settings.Notifications.NotificationsGotify.validationUrlTrailingSlash": "URL must not end in a trailing slash", "components.Settings.Notifications.NotificationsLunaSea.agentenabled": "Enable Agent", "components.Settings.Notifications.NotificationsLunaSea.profileName": "Profile Name", "components.Settings.Notifications.NotificationsLunaSea.profileNameTip": "Only required if not using the default profile", diff --git a/src/pages/settings/notifications/gotify.tsx b/src/pages/settings/notifications/gotify.tsx new file mode 100644 index 000000000..a47c9c288 --- /dev/null +++ b/src/pages/settings/notifications/gotify.tsx @@ -0,0 +1,20 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsGotify from '../../../components/Settings/Notifications/NotificationsGotify'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; +import useRouteGuard from '../../../hooks/useRouteGuard'; +import { Permission } from '../../../hooks/useUser'; + +const NotificationsPage: NextPage = () => { + useRouteGuard(Permission.MANAGE_SETTINGS); + return ( + + + + + + ); +}; + +export default NotificationsPage;