feat(notif): allow users to enable/disable specific agents (#1172)

* refactor(ui): add tabs to user notification settings

* feat(notif): allow users to enable/disable specific agents

* fix(ui): only enforce required fields when agent is enabled

* fix(ui): hide unavailable notification agents

* feat(notif): mention admin users for admin Discord notifications

* fix(ui): modify styling of PGP key textareas to suit expected input

* fix(notif): mention all admins when there are multiple and fix rebase error

* fix: add missing form values, and fix Yup validation

* refactor: reduce repeated logic/code in email notif agent

* refactor: move 'Notification Types' label into NotificationTypeSelector component

* fix(email): correct inconsistencies in email template formatting

* refactor: use bitfields for storing user-enabled notif agent types

* feat: improve notification agent logging

* fix(ui): mark string fields as nullable so empty values are not type errors

* fix: add validation for PGP-related inputs

* fix: correctly fetch user in user settings & log mentioned IDs for Discord notifs

* fix(ui): fix mobile nav dropdown text & add hover effect to button-style tabs

* fix(notif): process admin email notifications asynchronously

* fix(logging): log name of notification type instead of its enum value

* fix: mark required fields and pass all user settings values to API

* fix(frontend): call mutate after changing email/Discord/Telegram global notif settings

* refactor: get global notif settings from relevant API endpoints instead of adding to public settings

* fix(notif): fall back to email notifications being enabled (default) if user settings do not exist

* fix(notif): do not set notifyUser for MEDIA_PENDING or MEDIA_AUTO_APPROVED

* fix: expose notif enabled settings in user notif endpoints & remove global enable notif setting

* fix(notif): remove unnecessary allowed_mentions object from Discord payload

* fix(notif): use form values for email test notification

* fix: make suggested changes and regenerate DB migration

* fix: loosen validation of PGP keys

* fix: fix user profile settings routes

* fix: remove route guard from profile pages
This commit is contained in:
TheCatLady
2021-04-12 23:31:31 -04:00
committed by GitHub
parent bed850dce9
commit 46c4ee1625
50 changed files with 1727 additions and 1501 deletions

View File

@@ -145,7 +145,6 @@ export class MediaRequest {
subject: movie.title,
message: movie.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyUser: this.requestedBy,
media,
request: this,
});
@@ -157,7 +156,6 @@ export class MediaRequest {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
media,
extra: [
{
@@ -232,7 +230,7 @@ export class MediaRequest {
subject: tv.name,
message: tv.overview,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyUser: this.requestedBy,
notifyUser: autoApproved ? undefined : this.requestedBy,
media,
extra: [
{

View File

@@ -157,7 +157,8 @@ export class User {
logger.info(`Sending generated password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/generatedpassword'),
message: {
@@ -193,7 +194,7 @@ export class User {
logger.info(`Sending reset password email for ${this.email}`, {
label: 'User Management',
});
const email = new PreparedEmail();
const email = new PreparedEmail(getSettings().notifications.agents.email);
await email.send({
template: path.join(__dirname, '../templates/email/resetpassword'),
message: {

View File

@@ -5,6 +5,10 @@ import {
OneToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import {
hasNotificationAgentEnabled,
NotificationAgentType,
} from '../lib/notifications/agenttypes';
import { User } from './User';
@Entity()
@@ -20,8 +24,17 @@ export class UserSettings {
@JoinColumn()
public user: User;
@Column({ default: true })
public enableNotifications: boolean;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
@Column({ type: 'integer', default: NotificationAgentType.EMAIL })
public notificationAgents = NotificationAgentType.EMAIL;
@Column({ nullable: true })
public pgpKey?: string;
@Column({ nullable: true })
public discordId?: string;
@@ -32,12 +45,7 @@ export class UserSettings {
@Column({ nullable: true })
public telegramSendSilently?: boolean;
@Column({ nullable: true })
public region?: string;
@Column({ nullable: true })
public originalLanguage?: string;
@Column({ nullable: true })
public pgpKey?: string;
public hasNotificationAgentEnabled(agent: NotificationAgentType): boolean {
return !!hasNotificationAgentEnabled(agent, this.notificationAgents);
}
}

View File

@@ -13,10 +13,13 @@ export interface UserSettingsGeneralResponse {
}
export interface UserSettingsNotificationsResponse {
enableNotifications: boolean;
telegramBotUsername?: string;
notificationAgents: number;
emailEnabled?: boolean;
pgpKey?: string;
discordEnabled?: boolean;
discordId?: string;
telegramEnabled?: boolean;
telegramBotUsername?: string;
telegramChatId?: string;
telegramSendSilently?: boolean;
pgpKey?: string;
}

View File

@@ -1,11 +1,10 @@
import nodemailer from 'nodemailer';
import Email from 'email-templates';
import { getSettings } from '../settings';
import nodemailer from 'nodemailer';
import { NotificationAgentEmail } from '../settings';
import { openpgpEncrypt } from './openpgpEncrypt';
class PreparedEmail extends Email {
public constructor(pgpKey?: string) {
const settings = getSettings().notifications.agents.email;
class PreparedEmail extends Email {
public constructor(settings: NotificationAgentEmail, pgpKey?: string) {
const transport = nodemailer.createTransport({
host: settings.options.smtpHost,
port: settings.options.smtpPort,

View File

@@ -1,7 +1,11 @@
import axios from 'axios';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
import { User } from '../../../entity/User';
import logger from '../../../logger';
import { Permission } from '../../permissions';
import { getSettings, NotificationAgentDiscord } from '../../settings';
import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
enum EmbedColors {
@@ -107,7 +111,7 @@ class DiscordAgent
if (payload.request) {
fields.push({
name: 'Requested By',
value: payload.request?.requestedBy.displayName ?? '',
value: payload.request.requestedBy.displayName,
inline: true,
});
}
@@ -201,7 +205,14 @@ class DiscordAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Discord notification', { label: 'Notifications' });
logger.debug('Sending Discord notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
let content = undefined;
try {
const {
botUsername,
@@ -213,16 +224,32 @@ class DiscordAgent
return false;
}
const mentionedUsers: string[] = [];
let content = undefined;
if (payload.notifyUser) {
// Mention user who submitted the request
if (
payload.notifyUser.settings?.hasNotificationAgentEnabled(
NotificationAgentType.DISCORD
) &&
payload.notifyUser.settings?.discordId
) {
content = `<@${payload.notifyUser.settings.discordId}>`;
}
} else {
// Mention all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true) &&
payload.notifyUser.settings?.discordId
) {
mentionedUsers.push(payload.notifyUser.settings.discordId);
content = `<@${payload.notifyUser.settings.discordId}>`;
content = users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationAgentEnabled(
NotificationAgentType.DISCORD
) &&
user.settings?.discordId
)
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
}
await axios.post(webhookUrl, {
@@ -230,18 +257,19 @@ class DiscordAgent
avatar_url: botAvatarUrl,
embeds: [this.buildEmbed(type, payload)],
content,
allowed_mentions: {
users: mentionedUsers,
},
} as DiscordWebhookPayload);
return true;
} catch (e) {
logger.error('Error sending Discord notification', {
label: 'Notifications',
message: e.message,
mentions: content,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}

View File

@@ -1,3 +1,4 @@
import { EmailOptions } from 'email-templates';
import path from 'path';
import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..';
@@ -7,6 +8,7 @@ import logger from '../../../logger';
import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import { getSettings, NotificationAgentEmail } from '../../settings';
import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
class EmailAgent
@@ -35,379 +37,194 @@ class EmailAgent
return false;
}
private async sendMediaRequestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
private buildMessage(
type: Notification,
payload: NotificationPayload,
toEmail: string
): EmailOptions | undefined {
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(user.settings?.enableNotifications ?? true)
)
.forEach((user) => {
const email = new PreparedEmail(user.settings?.pgpKey);
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`,
},
});
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
if (type === Notification.TEST_NOTIFICATION) {
return {
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: toEmail,
},
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
};
}
}
private async sendMediaFailedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
if (payload.media) {
let requestType = '';
let body = '';
// Send to all users with the manage requests permission (or admins)
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(user.settings?.enableNotifications ?? true)
)
.forEach((user) => {
const email = new PreparedEmail(user.settings?.pgpKey);
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
}:`,
mediaName: payload.subject,
mediaPlot: payload.message,
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`,
},
});
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true)
) {
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`,
mediaName: payload.subject,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`,
},
});
switch (type) {
case Notification.MEDIA_PENDING:
requestType = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`;
break;
case Notification.MEDIA_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been approved:`;
break;
case Notification.MEDIA_AUTO_APPROVED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`;
break;
case Notification.MEDIA_AVAILABLE:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
body = `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`;
break;
case Notification.MEDIA_DECLINED:
requestType = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
body = `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`;
break;
case Notification.MEDIA_FAILED:
requestType = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} could not be added to ${
payload.media?.mediaType === MediaType.TV ? 'Sonarr' : 'Radarr'
}:`;
break;
}
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
return {
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: toEmail,
},
locals: {
requestType,
body,
mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
},
};
}
}
private async sendMediaAutoApprovedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
const userRepository = getRepository(User);
const users = await userRepository.find();
// Send to all users with the manage requests permission (or admins)
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(user.settings?.enableNotifications ?? true)
)
.forEach((user) => {
const email = new PreparedEmail();
email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: user.email,
},
locals: {
body: `A new request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} has been automatically approved:`,
mediaName: payload.subject,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`,
},
});
});
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaDeclinedEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true)
) {
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: `Your request for the following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} was declined:`,
mediaName: payload.subject,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`,
},
});
}
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendMediaAvailableEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true)
) {
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
await email.send({
template: path.join(
__dirname,
'../../../templates/email/media-request'
),
message: {
to: payload.notifyUser.email,
},
locals: {
body: `The following ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
} you requested is now available!`,
mediaName: payload.subject,
mediaExtra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName,
actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined,
applicationUrl,
applicationTitle,
requestType: `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`,
},
});
}
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
}
private async sendTestEmail(payload: NotificationPayload) {
// This is getting main settings for the whole app
const { applicationUrl, applicationTitle } = getSettings().main;
try {
if (payload.notifyUser) {
const email = new PreparedEmail(payload.notifyUser.settings?.pgpKey);
await email.send({
template: path.join(__dirname, '../../../templates/email/test-email'),
message: {
to: payload.notifyUser.email,
},
locals: {
body: payload.message,
applicationUrl,
applicationTitle,
},
});
}
return true;
} catch (e) {
logger.error('Email notification failed to send', {
label: 'Notifications',
message: e.message,
});
return false;
}
return undefined;
}
public async send(
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending email notification', { label: 'Notifications' });
if (payload.notifyUser) {
// Send notification to the user who submitted the request
if (
!payload.notifyUser.settings ||
payload.notifyUser.settings.hasNotificationAgentEnabled(
NotificationAgentType.EMAIL
)
) {
logger.debug('Sending email notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
switch (type) {
case Notification.MEDIA_PENDING:
this.sendMediaRequestEmail(payload);
break;
case Notification.MEDIA_APPROVED:
this.sendMediaApprovedEmail(payload);
break;
case Notification.MEDIA_AUTO_APPROVED:
this.sendMediaAutoApprovedEmail(payload);
break;
case Notification.MEDIA_DECLINED:
this.sendMediaDeclinedEmail(payload);
break;
case Notification.MEDIA_AVAILABLE:
this.sendMediaAvailableEmail(payload);
break;
case Notification.MEDIA_FAILED:
this.sendMediaFailedEmail(payload);
break;
case Notification.TEST_NOTIFICATION:
this.sendTestEmail(payload);
break;
try {
const email = new PreparedEmail(
this.getSettings(),
payload.notifyUser.settings?.pgpKey
);
await email.send(
this.buildMessage(type, payload, payload.notifyUser.email)
);
} catch (e) {
logger.error('Error sending email notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
return false;
}
}
} else {
// Send notifications to all users with the Manage Requests permission
const userRepository = getRepository(User);
const users = await userRepository.find();
await Promise.all(
users
.filter(
(user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings ||
user.settings.hasNotificationAgentEnabled(
NotificationAgentType.EMAIL
))
)
.map(async (user) => {
logger.debug('Sending email notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
const email = new PreparedEmail(
this.getSettings(),
user.settings?.pgpKey
);
await email.send(this.buildMessage(type, payload, user.email));
} catch (e) {
logger.error('Error sending email notification', {
label: 'Notifications',
recipient: user.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
});
return false;
}
})
);
}
return true;

View File

@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushbullet } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushbulletPayload {
title: string;
@@ -136,7 +136,12 @@ class PushbulletAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushbullet notification', { label: 'Notifications' });
logger.debug('Sending Pushbullet notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const endpoint = 'https://api.pushbullet.com/v2/pushes';
@@ -162,8 +167,12 @@ class PushbulletAgent
} catch (e) {
logger.error('Error sending Pushbullet notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}

View File

@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentPushover } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface PushoverPayload {
token: string;
@@ -160,7 +160,11 @@ class PushoverAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Pushover notification', { label: 'Notifications' });
logger.debug('Sending Pushover notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const endpoint = 'https://api.pushover.net/1/messages.json';
@@ -189,8 +193,12 @@ class PushoverAgent
} catch (e) {
logger.error('Error sending Pushover notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}

View File

@@ -1,9 +1,9 @@
import axios from 'axios';
import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
import { MediaType } from '../../../constants/media';
interface EmbedField {
type: 'plain_text' | 'mrkdwn';
@@ -67,9 +67,7 @@ class SlackAgent
if (payload.request) {
fields.push({
type: 'mrkdwn',
text: `*Requested By*\n${
payload.request?.requestedBy.displayName ?? ''
}`,
text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
});
}
@@ -235,7 +233,11 @@ class SlackAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Slack notification', { label: 'Notifications' });
logger.debug('Sending Slack notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const webhookUrl = this.getSettings().options.webhookUrl;
@@ -249,8 +251,12 @@ class SlackAgent
} catch (e) {
logger.error('Error sending Slack notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}

View File

@@ -3,6 +3,7 @@ import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media';
import logger from '../../../logger';
import { getSettings, NotificationAgentTelegram } from '../../settings';
import { NotificationAgentType } from '../agenttypes';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface TelegramMessagePayload {
@@ -155,62 +156,98 @@ class TelegramAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending Telegram notification', { label: 'Notifications' });
const endpoint = `${this.baseUrl}bot${this.getSettings().options.botAPI}/${
payload.image ? 'sendPhoto' : 'sendMessage'
}`;
// Send system notification
try {
const endpoint = `${this.baseUrl}bot${
this.getSettings().options.botAPI
}/${payload.image ? 'sendPhoto' : 'sendMessage'}`;
logger.debug('Sending Telegram notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
// Send system notification
await (payload.image
? axios.post(endpoint, {
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload));
// Send user notification
if (
payload.notifyUser &&
(payload.notifyUser.settings?.enableNotifications ?? true) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
await (payload.image
? axios.post(endpoint, {
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
chat_id: this.getSettings().options.chatId,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramPhotoPayload)
: axios.post(endpoint, {
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: `${payload.notifyUser.settings.telegramChatId}`,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload));
}
return true;
chat_id: `${this.getSettings().options.chatId}`,
disable_notification: this.getSettings().options.sendSilently,
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
message: e.message,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
if (
payload.notifyUser &&
payload.notifyUser.settings?.hasNotificationAgentEnabled(
NotificationAgentType.TELEGRAM
) &&
payload.notifyUser.settings?.telegramChatId &&
payload.notifyUser.settings?.telegramChatId !==
this.getSettings().options.chatId
) {
// Send notification to the user who submitted the request
logger.debug('Sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
});
try {
await axios.post(
endpoint,
payload.image
? ({
photo: payload.image,
caption: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramPhotoPayload)
: ({
text: this.buildMessage(type, payload),
parse_mode: 'MarkdownV2',
chat_id: payload.notifyUser.settings.telegramChatId,
disable_notification:
payload.notifyUser.settings.telegramSendSilently,
} as TelegramMessagePayload)
);
} catch (e) {
logger.error('Error sending Telegram notification', {
label: 'Notifications',
recipient: payload.notifyUser.displayName,
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}
return true;
}
}

View File

@@ -128,7 +128,12 @@ class WebhookAgent
type: Notification,
payload: NotificationPayload
): Promise<boolean> {
logger.debug('Sending webhook notification', { label: 'Notifications' });
logger.debug('Sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
});
try {
const { webhookUrl, authHeader } = this.getSettings().options;
@@ -146,8 +151,12 @@ class WebhookAgent
} catch (e) {
logger.error('Error sending webhook notification', {
label: 'Notifications',
type: Notification[type],
subject: payload.subject,
errorMessage: e.message,
response: e.response.data,
});
return false;
}
}

View File

@@ -0,0 +1,16 @@
export enum NotificationAgentType {
NONE = 0,
EMAIL = 2,
DISCORD = 4,
TELEGRAM = 8,
PUSHOVER = 16,
PUSHBULLET = 32,
SLACK = 64,
}
export const hasNotificationAgentEnabled = (
agent: NotificationAgentType,
value: number
): boolean => {
return !!(value & agent);
};

View File

@@ -38,7 +38,7 @@ class NotificationManager {
public registerAgents = (agents: NotificationAgent[]): void => {
this.activeAgents = [...this.activeAgents, ...agents];
logger.info('Registered Notification Agents', { label: 'Notifications' });
logger.info('Registered notification agents', { label: 'Notifications' });
};
public sendNotification(
@@ -46,8 +46,9 @@ class NotificationManager {
payload: NotificationPayload
): void {
const settings = getSettings().notifications;
logger.info(`Sending notification for ${Notification[type]}`, {
logger.info(`Sending notification(s) for ${Notification[type]}`, {
label: 'Notifications',
subject: payload.subject,
});
this.activeAgents.forEach((agent) => {
if (settings.enabled && agent.shouldSend(type)) {

View File

@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddUserSettingsNotificationAgentsField1617730837489
implements MigrationInterface {
name = 'AddUserSettingsNotificationAgentsField1617730837489';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" integer NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "notificationAgents" NOT NULL DEFAULT (2), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "notificationAgents", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "enableNotifications" boolean NOT NULL DEFAULT (1), "discordId" varchar, "userId" integer, "region" varchar, "originalLanguage" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "pgpKey" varchar, CONSTRAINT "UQ_986a2b6d3c05eb4091bb8066f78" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey") SELECT "id", "discordId", "userId", "region", "originalLanguage", "telegramChatId", "telegramSendSilently", "pgpKey" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

View File

@@ -1,36 +1,16 @@
import { Router } from 'express';
import { getSettings } from '../../lib/settings';
import { Notification } from '../../lib/notifications';
import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
import PushoverAgent from '../../lib/notifications/agents/pushover';
import SlackAgent from '../../lib/notifications/agents/slack';
import TelegramAgent from '../../lib/notifications/agents/telegram';
import PushoverAgent from '../../lib/notifications/agents/pushover';
import WebhookAgent from '../../lib/notifications/agents/webhook';
import PushbulletAgent from '../../lib/notifications/agents/pushbullet';
import { getSettings } from '../../lib/settings';
const notificationRoutes = Router();
notificationRoutes.get('/', (_req, res) => {
const settings = getSettings().notifications;
return res.status(200).json({
enabled: settings.enabled,
});
});
notificationRoutes.post('/', (req, res) => {
const settings = getSettings();
Object.assign(settings.notifications, {
enabled: req.body.enabled,
});
settings.save();
return res.status(200).json({
enabled: settings.notifications.enabled,
});
});
notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings();

View File

@@ -7,6 +7,7 @@ import {
UserSettingsGeneralResponse,
UserSettingsNotificationsResponse,
} from '../../interfaces/api/userSettingsInterfaces';
import { NotificationAgentType } from '../../lib/notifications/agenttypes';
import { Permission } from '../../lib/permissions';
import { getSettings } from '../../lib/settings';
import logger from '../../logger';
@@ -242,13 +243,17 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
return res.status(200).json({
enableNotifications: user.settings?.enableNotifications ?? true,
notificationAgents:
user.settings?.notificationAgents ?? NotificationAgentType.EMAIL,
emailEnabled: settings?.notifications.agents.email.enabled,
pgpKey: user.settings?.pgpKey,
discordEnabled: settings?.notifications.agents.discord.enabled,
discordId: user.settings?.discordId,
telegramEnabled: settings?.notifications.agents.telegram.enabled,
telegramBotUsername:
settings?.notifications.agents.telegram.options.botUsername,
discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
pgpKey: user?.settings?.pgpKey,
});
} catch (e) {
next({ status: 500, message: e.message });
@@ -256,60 +261,62 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
}
);
userSettingsRoutes.post<
{ id: string },
UserSettingsNotificationsResponse,
UserSettingsNotificationsResponse
>('/notifications', isOwnProfileOrAdmin(), async (req, res, next) => {
const userRepository = getRepository(User);
userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
'/notifications',
isOwnProfileOrAdmin(),
async (req, res, next) => {
const userRepository = getRepository(User);
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
// "Owner" user settings cannot be modified by other users
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: "You do not have permission to modify this user's settings.",
try {
const user = await userRepository.findOne({
where: { id: Number(req.params.id) },
});
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
enableNotifications: req.body.enableNotifications,
discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently,
pgpKey: req.body.pgpKey,
if (!user) {
return next({ status: 404, message: 'User not found.' });
}
// "Owner" user settings cannot be modified by other users
if (user.id === 1 && req.user?.id !== 1) {
return next({
status: 403,
message: "You do not have permission to modify this user's settings.",
});
}
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
notificationAgents:
req.body.notificationAgents ?? NotificationAgentType.EMAIL,
pgpKey: req.body.pgpKey,
discordId: req.body.discordId,
telegramChatId: req.body.telegramChatId,
telegramSendSilently: req.body.telegramSendSilently,
});
} else {
user.settings.notificationAgents =
req.body.notificationAgents ?? NotificationAgentType.EMAIL;
user.settings.pgpKey = req.body.pgpKey;
user.settings.discordId = req.body.discordId;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
}
userRepository.save(user);
return res.status(200).json({
notificationAgents: user.settings?.notificationAgents,
pgpKey: user.settings?.pgpKey,
discordId: user.settings?.discordId,
telegramChatId: user.settings?.telegramChatId,
telegramSendSilently: user?.settings?.telegramSendSilently,
});
} else {
user.settings.enableNotifications = req.body.enableNotifications;
user.settings.discordId = req.body.discordId;
user.settings.telegramChatId = req.body.telegramChatId;
user.settings.telegramSendSilently = req.body.telegramSendSilently;
user.settings.pgpKey = req.body.pgpKey;
} catch (e) {
next({ status: 500, message: e.message });
}
userRepository.save(user);
return res.status(200).json({
enableNotifications: user.settings.enableNotifications,
discordId: user.settings.discordId,
telegramChatId: user.settings.telegramChatId,
telegramSendSilently: user.settings.telegramSendSilently,
pgpKey: user.settings.pgpKey,
});
} catch (e) {
next({ status: 500, message: e.message });
}
});
);
userSettingsRoutes.get<{ id: string }, { permissions?: number }>(
'/permissions',

View File

@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')

View File

@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')

View File

@@ -42,7 +42,6 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
table(style='width: 100%' width='100%' cellpadding='0' cellspacing='0' role='presentation')
tr
td(align='center' style='\
font-size: 16px;\
padding-top: 25px;\
padding-bottom: 25px;\
text-align: center;\
@@ -50,7 +49,7 @@ div(role='article' aria-roledescription='email' aria-label='' lang='en')
a(href=applicationUrl style='\
text-shadow: 0 1px 0 #ffffff;\
font-weight: 700;\
font-size: 16px;\
font-size: 24px;\
color: #a8aaaf;\
text-decoration: none;\
')