parent
fb5c791b0b
commit
d8e542e5fe
@ -0,0 +1,13 @@
|
|||||||
|
import { Notification } from '..';
|
||||||
|
|
||||||
|
export interface NotificationPayload {
|
||||||
|
subject: string;
|
||||||
|
username?: string;
|
||||||
|
image?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationAgent {
|
||||||
|
shouldSend(type: Notification): boolean;
|
||||||
|
send(type: Notification, payload: NotificationPayload): Promise<boolean>;
|
||||||
|
}
|
@ -0,0 +1,157 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import { Notification } from '..';
|
||||||
|
import logger from '../../../logger';
|
||||||
|
import { getSettings } from '../../settings';
|
||||||
|
import type { NotificationAgent, NotificationPayload } from './agent';
|
||||||
|
|
||||||
|
enum EmbedColors {
|
||||||
|
DEFAULT = 0,
|
||||||
|
AQUA = 1752220,
|
||||||
|
GREEN = 3066993,
|
||||||
|
BLUE = 3447003,
|
||||||
|
PURPLE = 10181046,
|
||||||
|
GOLD = 15844367,
|
||||||
|
ORANGE = 15105570,
|
||||||
|
RED = 15158332,
|
||||||
|
GREY = 9807270,
|
||||||
|
DARKER_GREY = 8359053,
|
||||||
|
NAVY = 3426654,
|
||||||
|
DARK_AQUA = 1146986,
|
||||||
|
DARK_GREEN = 2067276,
|
||||||
|
DARK_BLUE = 2123412,
|
||||||
|
DARK_PURPLE = 7419530,
|
||||||
|
DARK_GOLD = 12745742,
|
||||||
|
DARK_ORANGE = 11027200,
|
||||||
|
DARK_RED = 10038562,
|
||||||
|
DARK_GREY = 9936031,
|
||||||
|
LIGHT_GREY = 12370112,
|
||||||
|
DARK_NAVY = 2899536,
|
||||||
|
LUMINOUS_VIVID_PINK = 16580705,
|
||||||
|
DARK_VIVID_PINK = 12320855,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordImageEmbed {
|
||||||
|
url?: string;
|
||||||
|
proxy_url?: string;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordRichEmbed {
|
||||||
|
title?: string;
|
||||||
|
type?: 'rich'; // Always rich for webhooks
|
||||||
|
description?: string;
|
||||||
|
url?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
color?: number;
|
||||||
|
footer?: {
|
||||||
|
text: string;
|
||||||
|
icon_url?: string;
|
||||||
|
proxy_icon_url?: string;
|
||||||
|
};
|
||||||
|
image?: DiscordImageEmbed;
|
||||||
|
thumbnail?: DiscordImageEmbed;
|
||||||
|
provider?: {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
author?: {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
icon_url?: string;
|
||||||
|
proxy_icon_url?: string;
|
||||||
|
};
|
||||||
|
fields?: {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
inline?: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscordWebhookPayload {
|
||||||
|
embeds: DiscordRichEmbed[];
|
||||||
|
username: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
tts: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscordAgent implements NotificationAgent {
|
||||||
|
public buildEmbed(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): DiscordRichEmbed {
|
||||||
|
let color = EmbedColors.DEFAULT;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case Notification.MEDIA_ADDED:
|
||||||
|
color = EmbedColors.ORANGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: payload.subject,
|
||||||
|
description: payload.message,
|
||||||
|
color,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
author: { name: 'Overseerr' },
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: 'Requested By',
|
||||||
|
value: payload.username ?? '',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Status',
|
||||||
|
value: 'Pending Approval',
|
||||||
|
inline: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
thumbnail: {
|
||||||
|
url: payload.image,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public shouldSend(type: Notification): boolean {
|
||||||
|
const settings = getSettings();
|
||||||
|
|
||||||
|
if (
|
||||||
|
settings.notifications.agents.discord?.enabled &&
|
||||||
|
settings.notifications.agents.discord?.options?.webhookUrl
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async send(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): Promise<boolean> {
|
||||||
|
const settings = getSettings();
|
||||||
|
logger.debug('Sending discord notification', { label: 'Notifications' });
|
||||||
|
try {
|
||||||
|
const webhookUrl = settings.notifications.agents.discord?.options
|
||||||
|
?.webhookUrl as string;
|
||||||
|
|
||||||
|
if (!webhookUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(webhookUrl, {
|
||||||
|
username: 'Overseerr',
|
||||||
|
embeds: [this.buildEmbed(type, payload)],
|
||||||
|
} as DiscordWebhookPayload);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('Error sending Discord notification', {
|
||||||
|
label: 'Notifications',
|
||||||
|
message: e.message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscordAgent;
|
@ -0,0 +1,33 @@
|
|||||||
|
import logger from '../../logger';
|
||||||
|
import type { NotificationAgent, NotificationPayload } from './agents/agent';
|
||||||
|
|
||||||
|
export enum Notification {
|
||||||
|
MEDIA_ADDED = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
class NotificationManager {
|
||||||
|
private activeAgents: NotificationAgent[] = [];
|
||||||
|
|
||||||
|
public registerAgents = (agents: NotificationAgent[]): void => {
|
||||||
|
this.activeAgents = [...this.activeAgents, ...agents];
|
||||||
|
logger.info('Registered Notification Agents', { label: 'Notifications' });
|
||||||
|
};
|
||||||
|
|
||||||
|
public sendNotification(
|
||||||
|
type: Notification,
|
||||||
|
payload: NotificationPayload
|
||||||
|
): void {
|
||||||
|
logger.info(`Sending notification for ${Notification[type]}`, {
|
||||||
|
label: 'Notifications',
|
||||||
|
});
|
||||||
|
this.activeAgents.forEach((agent) => {
|
||||||
|
if (agent.shouldSend(type)) {
|
||||||
|
agent.send(type, payload);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationManager = new NotificationManager();
|
||||||
|
|
||||||
|
export default notificationManager;
|
@ -0,0 +1,116 @@
|
|||||||
|
import React, { useState } 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';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
save: 'Save Changes',
|
||||||
|
saving: 'Saving...',
|
||||||
|
});
|
||||||
|
|
||||||
|
const NotificationsDiscord: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const { data, error, revalidate } = useSWR(
|
||||||
|
'/api/v1/settings/notifications/discord'
|
||||||
|
);
|
||||||
|
|
||||||
|
const NotificationsDiscordSchema = Yup.object().shape({
|
||||||
|
webhookUrl: Yup.string().required('You must provide a webhook URL'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data && !error) {
|
||||||
|
return <LoadingSpinner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={{
|
||||||
|
enabled: data.enabled,
|
||||||
|
types: data.types,
|
||||||
|
webhookUrl: data.options.webhookUrl,
|
||||||
|
}}
|
||||||
|
validationSchema={NotificationsDiscordSchema}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
try {
|
||||||
|
await Axios.post('/api/v1/settings/notifications/discord', {
|
||||||
|
enabled: values.enabled,
|
||||||
|
types: values.types,
|
||||||
|
options: {
|
||||||
|
webhookUrl: values.webhookUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// TODO show error
|
||||||
|
} finally {
|
||||||
|
revalidate();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ errors, touched, isSubmitting }) => {
|
||||||
|
return (
|
||||||
|
<Form>
|
||||||
|
<div className="sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-200 sm:pt-5">
|
||||||
|
<label
|
||||||
|
htmlFor="isDefault"
|
||||||
|
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||||
|
>
|
||||||
|
Agent Enabled
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
|
<Field
|
||||||
|
type="checkbox"
|
||||||
|
id="enabled"
|
||||||
|
name="enabled"
|
||||||
|
className="form-checkbox rounded-md h-6 w-6 text-indigo-600 transition duration-150 ease-in-out"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 sm:mt-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:items-start sm:border-t sm:border-gray-800 sm:pt-5">
|
||||||
|
<label
|
||||||
|
htmlFor="name"
|
||||||
|
className="block text-sm font-medium leading-5 text-gray-400 sm:mt-px sm:pt-2"
|
||||||
|
>
|
||||||
|
Webhook URL
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 sm:mt-0 sm:col-span-2">
|
||||||
|
<div className="max-w-lg flex rounded-md shadow-sm">
|
||||||
|
<Field
|
||||||
|
id="webhookUrl"
|
||||||
|
name="webhookUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="Server Settings -> Integrations -> Webhooks"
|
||||||
|
className="flex-1 form-input block w-full min-w-0 rounded-md transition duration-150 ease-in-out sm:text-sm sm:leading-5 bg-gray-700 border border-gray-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{errors.webhookUrl && touched.webhookUrl && (
|
||||||
|
<div className="text-red-500 mt-2">{errors.webhookUrl}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-8 border-t border-gray-700 pt-5">
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<span className="ml-3 inline-flex rounded-md shadow-sm">
|
||||||
|
<Button
|
||||||
|
buttonType="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? intl.formatMessage(messages.saving)
|
||||||
|
: intl.formatMessage(messages.save)}
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsDiscord;
|
@ -0,0 +1,107 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SettingsRoute {
|
||||||
|
text: string;
|
||||||
|
route: string;
|
||||||
|
regex: RegExp;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsRoutes: SettingsRoute[] = [
|
||||||
|
{
|
||||||
|
text: 'General',
|
||||||
|
route: '/settings/notifications',
|
||||||
|
regex: /^\/settings\/notifications$/,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Discord',
|
||||||
|
route: '/settings/notifications/discord',
|
||||||
|
regex: /^\/settings\/notifications\/discord/,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const SettingsNotifications: React.FC = ({ children }) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const activeLinkColor = 'bg-gray-700';
|
||||||
|
|
||||||
|
const inactiveLinkColor = '';
|
||||||
|
|
||||||
|
const SettingsLink: React.FC<{
|
||||||
|
route: string;
|
||||||
|
regex: RegExp;
|
||||||
|
isMobile?: boolean;
|
||||||
|
}> = ({ children, route, regex, isMobile = false }) => {
|
||||||
|
if (isMobile) {
|
||||||
|
return <option value={route}>{children}</option>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link href={route}>
|
||||||
|
<a
|
||||||
|
className={`whitespace-nowrap ml-8 first:ml-0 px-3 py-2 font-medium text-sm rounded-md ${
|
||||||
|
!!router.pathname.match(regex) ? activeLinkColor : inactiveLinkColor
|
||||||
|
}`}
|
||||||
|
aria-current="page"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="sm:hidden">
|
||||||
|
<label htmlFor="tabs" className="sr-only">
|
||||||
|
Select a tab
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
onChange={(e) => {
|
||||||
|
router.push(e.target.value);
|
||||||
|
}}
|
||||||
|
onBlur={(e) => {
|
||||||
|
router.push(e.target.value);
|
||||||
|
}}
|
||||||
|
defaultValue={
|
||||||
|
settingsRoutes.find(
|
||||||
|
(route) => !!router.pathname.match(route.regex)
|
||||||
|
)?.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"
|
||||||
|
>
|
||||||
|
{settingsRoutes.map((route, index) => (
|
||||||
|
<SettingsLink
|
||||||
|
route={route.route}
|
||||||
|
regex={route.regex}
|
||||||
|
isMobile
|
||||||
|
key={`mobile-settings-link-${index}`}
|
||||||
|
>
|
||||||
|
{route.text}
|
||||||
|
</SettingsLink>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<nav className="flex space-x-4" aria-label="Tabs">
|
||||||
|
{settingsRoutes.map((route, index) => (
|
||||||
|
<SettingsLink
|
||||||
|
route={route.route}
|
||||||
|
regex={route.regex}
|
||||||
|
key={`standard-settings-link-${index}`}
|
||||||
|
>
|
||||||
|
{route.text}
|
||||||
|
</SettingsLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10">{children}</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SettingsNotifications;
|
@ -0,0 +1,17 @@
|
|||||||
|
import { NextPage } from 'next';
|
||||||
|
import React from 'react';
|
||||||
|
import NotificationsDiscord from '../../../components/Settings/Notifications/NotificationsDiscord';
|
||||||
|
import SettingsLayout from '../../../components/Settings/SettingsLayout';
|
||||||
|
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
|
||||||
|
|
||||||
|
const NotificationsPage: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<SettingsNotifications>
|
||||||
|
<NotificationsDiscord />
|
||||||
|
</SettingsNotifications>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsPage;
|
@ -0,0 +1,14 @@
|
|||||||
|
import { NextPage } from 'next';
|
||||||
|
import React from 'react';
|
||||||
|
import SettingsLayout from '../../../components/Settings/SettingsLayout';
|
||||||
|
import SettingsNotifications from '../../../components/Settings/SettingsNotifications';
|
||||||
|
|
||||||
|
const NotificationsPage: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<SettingsLayout>
|
||||||
|
<SettingsNotifications>N/A</SettingsNotifications>
|
||||||
|
</SettingsLayout>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationsPage;
|
Loading…
Reference in new issue