diff --git a/overseerr-api.yml b/overseerr-api.yml index 3e4b2adb3..6fe132187 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -841,6 +841,20 @@ components: properties: webhookUrl: type: string + SlackSettings: + type: object + properties: + enabled: + type: boolean + example: false + types: + type: number + example: 2 + options: + type: object + properties: + webhookUrl: + type: string NotificationEmailSettings: type: object properties: @@ -1621,6 +1635,52 @@ paths: responses: '204': description: Test notification attempted + /settings/notifications/slack: + get: + summary: Return current slack notification settings + description: Returns current slack notification settings in JSON format + tags: + - settings + responses: + '200': + description: Returned slack settings + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + post: + summary: Update slack notification settings + description: Update current slack notification settings with provided values + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '200': + description: 'Values were sucessfully updated' + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + /settings/notifications/slack/test: + post: + summary: Test the provided slack settings + description: Sends a test notification to the slack agent + tags: + - settings + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SlackSettings' + responses: + '204': + description: Test notification attempted /settings/about: get: summary: Return current about stats diff --git a/server/index.ts b/server/index.ts index 857232d07..76371a94c 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,7 @@ import notificationManager from './lib/notifications'; import DiscordAgent from './lib/notifications/agents/discord'; import EmailAgent from './lib/notifications/agents/email'; import { getAppVersion } from './utils/appVersion'; +import SlackAgent from './lib/notifications/agents/slack'; const API_SPEC_PATH = path.join(__dirname, '../overseerr-api.yml'); @@ -42,7 +43,11 @@ app const settings = getSettings().load(); // Register Notification Agents - notificationManager.registerAgents([new DiscordAgent(), new EmailAgent()]); + notificationManager.registerAgents([ + new DiscordAgent(), + new EmailAgent(), + new SlackAgent(), + ]); // Start Jobs startJobs(); diff --git a/server/lib/notifications/agents/slack.ts b/server/lib/notifications/agents/slack.ts new file mode 100644 index 000000000..221228d85 --- /dev/null +++ b/server/lib/notifications/agents/slack.ts @@ -0,0 +1,225 @@ +import axios from 'axios'; +import { Notification } from '..'; +import logger from '../../../logger'; +import { getSettings, NotificationAgentSlack } from '../../settings'; +import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; + +interface EmbedField { + type: 'plain_text' | 'mrkdwn'; + text: string; +} + +interface TextItem { + type: 'plain_text' | 'mrkdwn'; + text: string; + emoji?: boolean; +} + +interface Element { + type: 'button'; + text?: TextItem; + value: string; + url: string; + action_id: 'button-action'; +} + +interface EmbedBlock { + type: 'header' | 'actions' | 'section' | 'context'; + block_id?: 'section789'; + text?: TextItem; + fields?: EmbedField[]; + accessory?: { + type: 'image'; + image_url: string; + alt_text: string; + }; + elements?: Element[]; +} + +interface SlackBlockEmbed { + blocks: EmbedBlock[]; +} + +class SlackAgent + extends BaseAgent + implements NotificationAgent { + protected getSettings(): NotificationAgentSlack { + if (this.settings) { + return this.settings; + } + + const settings = getSettings(); + + return settings.notifications.agents.slack; + } + + public buildEmbed( + type: Notification, + payload: NotificationPayload + ): SlackBlockEmbed { + const settings = getSettings(); + let header = 'Overseerr'; + let actionUrl: string | undefined; + + const fields: EmbedField[] = []; + + switch (type) { + case Notification.MEDIA_PENDING: + header = 'New Request'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nPending Approval', + } + ); + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + case Notification.MEDIA_APPROVED: + header = 'Request Approved'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nProcessing Request', + } + ); + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + case Notification.MEDIA_AVAILABLE: + header = 'Now available!'; + fields.push( + { + type: 'mrkdwn', + text: `*Requested By*\n${payload.notifyUser.username ?? ''}`, + }, + { + type: 'mrkdwn', + text: '*Status*\nAvailable', + } + ); + + if (settings.main.applicationUrl) { + actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`; + } + break; + } + + const blocks: EmbedBlock[] = [ + { + type: 'header', + text: { + type: 'plain_text', + text: header, + }, + }, + { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${payload.subject}*`, + }, + }, + ]; + + if (payload.message) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: payload.message, + }, + accessory: payload.image + ? { + type: 'image', + image_url: payload.image, + alt_text: payload.subject, + } + : undefined, + }); + } + + if (fields.length > 0) { + blocks.push({ + type: 'section', + fields: [ + ...fields, + ...(payload.extra ?? []).map( + (extra): EmbedField => ({ + type: 'mrkdwn', + text: `*${extra.name}*\n${extra.value}`, + }) + ), + ], + }); + } + + if (actionUrl) { + blocks.push({ + type: 'actions', + elements: [ + { + action_id: 'button-action', + type: 'button', + url: actionUrl, + value: 'open_overseerr', + text: { + type: 'plain_text', + text: 'Open Overseerr', + }, + }, + ], + }); + } + + return { + blocks, + }; + } + + // TODO: Add checking for type here once we add notification type filters for agents + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public shouldSend(_type: Notification): boolean { + if (this.getSettings().enabled && this.getSettings().options.webhookUrl) { + return true; + } + + return false; + } + + public async send( + type: Notification, + payload: NotificationPayload + ): Promise { + logger.debug('Sending slack notification', { label: 'Notifications' }); + try { + const webhookUrl = this.getSettings().options.webhookUrl; + + if (!webhookUrl) { + return false; + } + + await axios.post(webhookUrl, this.buildEmbed(type, payload)); + + return true; + } catch (e) { + logger.error('Error sending Slack notification', { + label: 'Notifications', + message: e.message, + }); + return false; + } + } +} + +export default SlackAgent; diff --git a/server/lib/settings.ts b/server/lib/settings.ts index b0d2c45fa..c3cdfee66 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -66,6 +66,12 @@ export interface NotificationAgentDiscord extends NotificationAgentConfig { }; } +export interface NotificationAgentSlack extends NotificationAgentConfig { + options: { + webhookUrl: string; + }; +} + export interface NotificationAgentEmail extends NotificationAgentConfig { options: { emailFrom: string; @@ -81,6 +87,7 @@ export interface NotificationAgentEmail extends NotificationAgentConfig { interface NotificationAgents { email: NotificationAgentEmail; discord: NotificationAgentDiscord; + slack: NotificationAgentSlack; } interface NotificationSettings { @@ -142,6 +149,13 @@ class Settings { webhookUrl: '', }, }, + slack: { + enabled: false, + types: 0, + options: { + webhookUrl: '', + }, + }, }, }, }; diff --git a/server/routes/settings.ts b/server/routes/settings.ts index a01722210..4f22fe01f 100644 --- a/server/routes/settings.ts +++ b/server/routes/settings.ts @@ -24,6 +24,7 @@ import { SettingsAboutResponse } from '../interfaces/api/settingsInterfaces'; import { Notification } from '../lib/notifications'; import DiscordAgent from '../lib/notifications/agents/discord'; import EmailAgent from '../lib/notifications/agents/email'; +import SlackAgent from '../lib/notifications/agents/slack'; const settingsRoutes = Router(); @@ -468,6 +469,40 @@ settingsRoutes.post('/notifications/discord/test', (req, res, next) => { return res.status(204).send(); }); +settingsRoutes.get('/notifications/slack', (_req, res) => { + const settings = getSettings(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +settingsRoutes.post('/notifications/slack', (req, res) => { + const settings = getSettings(); + + settings.notifications.agents.slack = req.body; + settings.save(); + + res.status(200).json(settings.notifications.agents.slack); +}); + +settingsRoutes.post('/notifications/slack/test', (req, res, next) => { + if (!req.user) { + return next({ + status: 500, + message: 'User information missing from request', + }); + } + + const slackAgent = new SlackAgent(req.body); + slackAgent.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/discord_white.svg b/src/assets/extlogos/discord_white.svg new file mode 100644 index 000000000..50ef8d292 --- /dev/null +++ b/src/assets/extlogos/discord_white.svg @@ -0,0 +1 @@ + diff --git a/src/assets/extlogos/slack.svg b/src/assets/extlogos/slack.svg new file mode 100644 index 000000000..dbcfb00b9 --- /dev/null +++ b/src/assets/extlogos/slack.svg @@ -0,0 +1 @@ + diff --git a/src/components/Common/Alert/index.tsx b/src/components/Common/Alert/index.tsx index 60a9edca2..c5c9c6afd 100644 --- a/src/components/Common/Alert/index.tsx +++ b/src/components/Common/Alert/index.tsx @@ -2,31 +2,66 @@ import React from 'react'; interface AlertProps { title: string; - type?: 'warning'; + type?: 'warning' | 'info'; } -const Alert: React.FC = ({ title, children }) => { - return ( -
-
-
+const Alert: React.FC = ({ title, children, type }) => { + let design = { + color: 'yellow', + svg: ( + + ), + }; + + switch (type) { + case 'info': + design = { + color: 'indigo', + svg: ( + ), + }; + break; + } + + return ( +
+
+
+ {design.svg}
-

{title}

-
{children}
+

+ {title} +

+
+ {children} +
diff --git a/src/components/Settings/Notifications/NotificationsSlack/index.tsx b/src/components/Settings/Notifications/NotificationsSlack/index.tsx new file mode 100644 index 000000000..9808f2103 --- /dev/null +++ b/src/components/Settings/Notifications/NotificationsSlack/index.tsx @@ -0,0 +1,189 @@ +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'; + +const messages = defineMessages({ + save: 'Save Changes', + saving: 'Saving...', + agentenabled: 'Agent Enabled', + webhookUrl: 'Webhook URL', + validationWebhookUrlRequired: 'You must provide a webhook URL', + webhookUrlPlaceholder: 'Webhook URL', + slacksettingssaved: 'Slack notification settings saved!', + slacksettingsfailed: 'Slack notification settings failed to save.', + testsent: 'Test notification sent!', + test: 'Test', + settingupslack: 'Setting up Slack Notifications', + settingupslackDescription: + 'To use Slack notifications, you will need to create an Incoming Webhook integration and use the provided webhook URL below.', +}); + +const NotificationsSlack: React.FC = () => { + const intl = useIntl(); + const { addToast } = useToasts(); + const { data, error, revalidate } = useSWR( + '/api/v1/settings/notifications/slack' + ); + + const NotificationsSlackSchema = Yup.object().shape({ + webhookUrl: Yup.string().required( + intl.formatMessage(messages.validationWebhookUrlRequired) + ), + }); + + if (!data && !error) { + return ; + } + + return ( + <> +

+ + {intl.formatMessage(messages.settingupslackDescription, { + WebhookLink: function WebhookLink(msg) { + return ( + + {msg} + + ); + }, + })} + +

+ { + try { + await axios.post('/api/v1/settings/notifications/slack', { + enabled: values.enabled, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + addToast(intl.formatMessage(messages.slacksettingssaved), { + appearance: 'success', + autoDismiss: true, + }); + } catch (e) { + addToast(intl.formatMessage(messages.slacksettingsfailed), { + appearance: 'error', + autoDismiss: true, + }); + } finally { + revalidate(); + } + }} + > + {({ errors, touched, isSubmitting, values, isValid }) => { + const testSettings = async () => { + await axios.post('/api/v1/settings/notifications/slack/test', { + enabled: true, + types: values.types, + options: { + webhookUrl: values.webhookUrl, + }, + }); + + addToast(intl.formatMessage(messages.testsent), { + appearance: 'info', + autoDismiss: true, + }); + }; + + return ( +
+
+ +
+ +
+
+
+ +
+
+ +
+ {errors.webhookUrl && touched.webhookUrl && ( +
{errors.webhookUrl}
+ )} +
+
+
+
+ + + + + + +
+
+
+ ); + }} +
+ + ); +}; + +export default NotificationsSlack; diff --git a/src/components/Settings/SettingsNotifications.tsx b/src/components/Settings/SettingsNotifications.tsx index 750595899..d7a0ba3f9 100644 --- a/src/components/Settings/SettingsNotifications.tsx +++ b/src/components/Settings/SettingsNotifications.tsx @@ -2,6 +2,8 @@ import Link from 'next/link'; import { useRouter } from 'next/router'; import React from 'react'; import { defineMessages, useIntl } from 'react-intl'; +import DiscordLogo from '../../assets/extlogos/discord_white.svg'; +import SlackLogo from '../../assets/extlogos/slack.svg'; const messages = defineMessages({ notificationsettings: 'Notification Settings', @@ -10,31 +12,64 @@ const messages = defineMessages({ }); interface SettingsRoute { - text: string; + text: React.ReactNode; route: string; regex: RegExp; } const settingsRoutes: SettingsRoute[] = [ { - text: 'Email', + text: ( + + + + + Email + + ), route: '/settings/notifications/email', regex: /^\/settings\/notifications\/email/, }, { - text: 'Discord', + text: ( + + + Discord + + ), route: '/settings/notifications/discord', regex: /^\/settings\/notifications\/discord/, }, + { + text: ( + + + Slack + + ), + route: '/settings/notifications/slack', + regex: /^\/settings\/notifications\/slack/, + }, ]; const SettingsNotifications: React.FC = ({ children }) => { const router = useRouter(); const intl = useIntl(); - const activeLinkColor = 'bg-gray-700'; + const activeLinkColor = 'bg-indigo-700'; - const inactiveLinkColor = ''; + const inactiveLinkColor = 'bg-gray-800'; const SettingsLink: React.FC<{ route: string; @@ -62,10 +97,10 @@ const SettingsNotifications: React.FC = ({ children }) => { return ( <>
-

+

{intl.formatMessage(messages.notificationsettings)}

-

+

{intl.formatMessage(messages.notificationsettingsDescription)}

@@ -87,7 +122,7 @@ const SettingsNotifications: React.FC = ({ children }) => { )?.route } aria-label="Selected tab" - className="bg-gray-800 text-white mt-1 rounded-md form-select block w-full pl-3 pr-10 py-2 text-base leading-6 border-gray-700 focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5 transition ease-in-out duration-150" + className="block w-full py-2 pl-3 pr-10 mt-1 text-base leading-6 text-white transition duration-150 ease-in-out bg-gray-800 border-gray-700 rounded-md form-select focus:outline-none focus:ring-blue focus:border-blue-300 sm:text-sm sm:leading-5" > {settingsRoutes.map((route, index) => ( Incoming Webhook integration and use the provided webhook URL below.", + "components.Settings.Notifications.NotificationsSlack.slacksettingsfailed": "Slack notification settings failed to save.", + "components.Settings.Notifications.NotificationsSlack.slacksettingssaved": "Slack notification settings saved!", + "components.Settings.Notifications.NotificationsSlack.test": "Test", + "components.Settings.Notifications.NotificationsSlack.testsent": "Test notification sent!", + "components.Settings.Notifications.NotificationsSlack.validationWebhookUrlRequired": "You must provide a webhook URL", + "components.Settings.Notifications.NotificationsSlack.webhookUrl": "Webhook URL", + "components.Settings.Notifications.NotificationsSlack.webhookUrlPlaceholder": "Webhook URL", "components.Settings.Notifications.agentenabled": "Agent Enabled", "components.Settings.Notifications.allowselfsigned": "Allow Self-Signed Certificates", "components.Settings.Notifications.authPass": "Auth Pass", @@ -330,6 +342,10 @@ "components.UserEdit.adminDescription": "Full administrator access. Bypasses all permission checks.", "components.UserEdit.autoapprove": "Auto Approve", "components.UserEdit.autoapproveDescription": "Grants auto approval for any requests made by this user.", + "components.UserEdit.autoapproveMovies": "Auto Approve Movies", + "components.UserEdit.autoapproveMoviesDescription": "Grants auto approve for movie requests made by this user.", + "components.UserEdit.autoapproveSeries": "Auto Approve Series", + "components.UserEdit.autoapproveSeriesDescription": "Grants auto approve for series requests made by this user.", "components.UserEdit.avatar": "Avatar", "components.UserEdit.edituser": "Edit User", "components.UserEdit.email": "Email", diff --git a/src/pages/settings/notifications/slack.tsx b/src/pages/settings/notifications/slack.tsx new file mode 100644 index 000000000..bee2e8133 --- /dev/null +++ b/src/pages/settings/notifications/slack.tsx @@ -0,0 +1,17 @@ +import { NextPage } from 'next'; +import React from 'react'; +import NotificationsSlack from '../../../components/Settings/Notifications/NotificationsSlack'; +import SettingsLayout from '../../../components/Settings/SettingsLayout'; +import SettingsNotifications from '../../../components/Settings/SettingsNotifications'; + +const NotificationsSlackPage: NextPage = () => { + return ( + + + + + + ); +}; + +export default NotificationsSlackPage;