feat(notif): issue notifications (#2242)

* feat(notif): issue notifications

* refactor: dedupe test notification strings

* fix: webhook key parsing

* fix(notif): skip send for admin who requested on behalf of another user

* fix(notif): send comment notifs to admins when other admins reply

* fix(notif): also send resolved notifs to admins, and reopened notifs to issue creator

* fix: don't send duplicate notifications

* fix(lang): tweak notification description strings

* fix(notif): tweak Slack notification styling

* fix(notif): tweak Pushbullet & Telegram notification styling

* docs: reformat webhooks page

* fix(notif): add missing issue_type & issue_status variables to LunaSea notif payloads

* fix: explicitly attach media & issue objects where applicable

* fix(notif): correctly notify both notifyUser and managers where applicable

* fix: update default webhook payload for new installs

* fix(notif): add missing comment_message to LunaSea notif payload

* refactor(sw): simplify notificationclick event listener logic

* fix(notif): add missing event description for MEDIA_AVAILABLE notifications
pull/2254/head
TheCatLady 2 years ago committed by GitHub
parent 6245be1e10
commit c9ffac33f7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -24,23 +24,28 @@ Customize the JSON payload to suit your needs. Overseerr provides several [templ
### General ### General
- `{{notification_type}}` The type of notification. (Ex. `MEDIA_PENDING` or `MEDIA_APPROVED`) | Variable | Value |
- `{{subject}}` The notification subject message. (For request notifications, this is the media title) | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
- `{{message}}` Notification message body. (For request notifications, this is the media's overview/synopsis) | `{{notification_type}}` | The type of notification (e.g. `MEDIA_PENDING` or `ISSUE_COMMENT`) |
- `{{image}}` Associated image with the request. (For request notifications, this is the media's poster) | `{{event}}` | A friendly description of the notification event |
| `{{subject}}` | The notification subject (typically the media title) |
| `{{message}}` | The notification message body (the media overview/synopsis for request notifications; the issue description for issue notificatons) |
| `{{image}}` | The notification image (typically the media poster) |
### User ### Notify User
These variables are for the target recipient of the notification. These variables are for the target recipient of the notification.
- `{{notifyuser_username}}` Target user's username. | Variable | Value |
- `{{notifyuser_email}}` Target user's email address. | ---------------------------------------- | ------------------------------------------------------------- |
- `{{notifyuser_avatar}}` Target user's avatar URL. | `{{notifyuser_username}}` | The target notification recipient's username |
- `{{notifyuser_settings_discordId}}` Target user's Discord ID (if one is set). | `{{notifyuser_email}}` | The target notification recipient's email address |
- `{{notifyuser_settings_telegramChatId}}` Target user's Telegram Chat ID (if one is set). | `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
{% hint style="info" %} {% hint style="info" %}
The `notifyuser` variables are not set for the following notification types, as they are intended for application administrators rather than end users: The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
- Media Requested - Media Requested
- Media Automatically Approved - Media Automatically Approved
@ -59,28 +64,69 @@ If you would like to use the requesting user's information in your webhook, plea
The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`). The following variables must be used as a key in the JSON payload (e.g., `"{{extra}}": []`).
- `{{request}}` This object will be `null` if there is no relevant request object for the notification. | Variable | Value |
- `{{media}}` This object will be `null` if there is no relevant media object for the notification. | ------------- | ------------------------------------------------------------------------------------------------------------------------------ |
- `{{extra}}` This object will contain the "extra" array of additional data for certain notifications. | `{{media}}` | The relevant media object |
| `{{request}}` | The relevant request object |
| `{{issue}}` | The relevant issue object |
| `{{comment}}` | The relevant issue comment object |
| `{{extra}}` | The "extra" array of additional data for certain notifications (e.g., season/episode numbers for series-related notifications) |
#### Media #### Media
These `{{media}}` special variables are only included in media-related notifications, such as requests. The `{{media}}` will be `null` if there is no relevant media object for the notification.
- `{{media_type}}` Media type (`movie` or `tv`). These following special variables are only included in media-related notifications, such as requests.
- `{{media_tmdbid}}` Media's TMDb ID.
- `{{media_imdbid}}` Media's IMDb ID. | Variable | Value |
- `{{media_tvdbid}}` Media's TVDB ID. | -------------------- | -------------------------------------------------------------------------------------------------------------- |
- `{{media_status}}` Media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`). | `{{media_type}}` | The media type (`movie` or `tv`) |
- `{{media_status4k}}` Media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) | `{{media_tmdbid}}` | The media's TMDb ID |
| `{{media_tvdbid}}` | The media's TheTVDB ID |
| `{{media_status}}` | The media's availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
| `{{media_status4k}}` | The media's 4K availability status (`UNKNOWN`, `PENDING`, `PROCESSING`, `PARTIALLY_AVAILABLE`, or `AVAILABLE`) |
#### Request #### Request
The `{{request}}` special variables are only included in request-related notifications. The `{{request}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in request-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{request_id}}` | The request ID |
| `{{requestedBy_username}}` | The requesting user's username |
| `{{requestedBy_email}}` | The requesting user's email address |
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue-related notifications.
| Variable | Value |
| ---------------------------------------- | ----------------------------------------------- |
| `{{issue_id}}` | The issue ID |
| `{{reportedBy_username}}` | The requesting user's username |
| `{{reportedBy_email}}` | The requesting user's email address |
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
The following special variables are only included in issue comment-related notifications.
- `{{request_id}}` Request ID. | Variable | Value |
- `{{requestedBy_username}}` Requesting user's username. | ----------------------------------------- | ----------------------------------------------- |
- `{{requestedBy_email}}` Requesting user's email address. | `{{comment_message}}` | The comment message |
- `{{requestedBy_avatar}}` Requesting user's avatar URL. | `{{commentedBy_username}}` | The commenting user's username |
- `{{requestedBy_settings_discordId}}` Requesting user's Discord ID (if set). | `{{commentedBy_email}}` | The commenting user's email address |
- `{{requestedBy_settings_telegramChatId}}` Requesting user's Telegram Chat ID (if set). | `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

@ -90,8 +90,8 @@ self.addEventListener('push', (event) => {
if (payload.actionUrl){ if (payload.actionUrl){
options.actions.push( options.actions.push(
{ {
action: 'viewmedia', action: 'view',
title: 'View Media', title: payload.actionUrlTitle ?? 'View',
} }
); );
} }
@ -119,21 +119,17 @@ self.addEventListener('notificationclick', (event) => {
event.notification.close(); event.notification.close();
if (event.action === 'viewmedia') { if (event.action === 'approve') {
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'approve') {
fetch(`/api/v1/request/${notificationData.requestId}/approve`, { fetch(`/api/v1/request/${notificationData.requestId}/approve`, {
method: 'POST', method: 'POST',
}); });
clients.openWindow(notificationData.actionUrl);
} else if (event.action === 'decline') { } else if (event.action === 'decline') {
fetch(`/api/v1/request/${notificationData.requestId}/decline`, { fetch(`/api/v1/request/${notificationData.requestId}/decline`, {
method: 'POST', method: 'POST',
}); });
}
clients.openWindow(notificationData.actionUrl);
} else if (notificationData.actionUrl) { if (notificationData.actionUrl) {
clients.openWindow(notificationData.actionUrl); clients.openWindow(notificationData.actionUrl);
} }
}, false); }, false);

@ -10,9 +10,9 @@ export enum IssueStatus {
RESOLVED = 2, RESOLVED = 2,
} }
export const IssueTypeNames = { export const IssueTypeName = {
[IssueType.AUDIO]: 'Audio', [IssueType.AUDIO]: 'Audio',
[IssueType.VIDEO]: 'Video', [IssueType.VIDEO]: 'Video',
[IssueType.SUBTITLES]: 'Subtitles', [IssueType.SUBTITLES]: 'Subtitle',
[IssueType.OTHER]: 'Other', [IssueType.OTHER]: 'Other',
}; };

@ -142,6 +142,7 @@ export class MediaRequest {
if (this.type === MediaType.MOVIE) { if (this.type === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId }); const movie = await tmdb.getMovie({ movieId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, { notificationManager.sendNotification(Notification.MEDIA_PENDING, {
event: 'New Movie Request',
subject: `${movie.title}${ subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`, }`,
@ -153,12 +154,14 @@ export class MediaRequest {
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
media, media,
request: this, request: this,
notifyAdmin: true,
}); });
} }
if (this.type === MediaType.TV) { if (this.type === MediaType.TV) {
const tv = await tmdb.getTvShow({ tvId: media.tmdbId }); const tv = await tmdb.getTvShow({ tvId: media.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_PENDING, { notificationManager.sendNotification(Notification.MEDIA_PENDING, {
event: 'New Series Request',
subject: `${tv.name}${ subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`, }`,
@ -171,13 +174,14 @@ export class MediaRequest {
media, media,
extra: [ extra: [
{ {
name: 'Seasons', name: 'Requested Seasons',
value: this.seasons value: this.seasons
.map((season) => season.seasonNumber) .map((season) => season.seasonNumber)
.join(', '), .join(', '),
}, },
], ],
request: this, request: this,
notifyAdmin: true,
}); });
} }
} }
@ -222,6 +226,13 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED : Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED, : Notification.MEDIA_DECLINED,
{ {
event: `Movie Request ${
this.status === MediaRequestStatus.APPROVED
? autoApproved
? 'Automatically Approved'
: 'Approved'
: 'Declined'
}`,
subject: `${movie.title}${ subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`, }`,
@ -231,6 +242,7 @@ export class MediaRequest {
omission: '…', omission: '…',
}), }),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
notifyAdmin: autoApproved,
notifyUser: autoApproved ? undefined : this.requestedBy, notifyUser: autoApproved ? undefined : this.requestedBy,
media, media,
request: this, request: this,
@ -245,6 +257,13 @@ export class MediaRequest {
: Notification.MEDIA_APPROVED : Notification.MEDIA_APPROVED
: Notification.MEDIA_DECLINED, : Notification.MEDIA_DECLINED,
{ {
event: `Series Request ${
this.status === MediaRequestStatus.APPROVED
? autoApproved
? 'Automatically Approved'
: 'Approved'
: 'Declined'
}`,
subject: `${tv.name}${ subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`, }`,
@ -254,11 +273,12 @@ export class MediaRequest {
omission: '…', omission: '…',
}), }),
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
notifyAdmin: autoApproved,
notifyUser: autoApproved ? undefined : this.requestedBy, notifyUser: autoApproved ? undefined : this.requestedBy,
media, media,
extra: [ extra: [
{ {
name: 'Seasons', name: 'Requested Seasons',
value: this.seasons value: this.seasons
.map((season) => season.seasonNumber) .map((season) => season.seasonNumber)
.join(', '), .join(', '),
@ -508,6 +528,7 @@ export class MediaRequest {
); );
notificationManager.sendNotification(Notification.MEDIA_FAILED, { notificationManager.sendNotification(Notification.MEDIA_FAILED, {
event: `Movie Request Failed`,
subject: `${movie.title}${ subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`, }`,
@ -519,6 +540,7 @@ export class MediaRequest {
media, media,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: this, request: this,
notifyAdmin: true,
}); });
}); });
logger.info('Sent request to Radarr', { label: 'Media Request' }); logger.info('Sent request to Radarr', { label: 'Media Request' });
@ -722,6 +744,7 @@ export class MediaRequest {
); );
notificationManager.sendNotification(Notification.MEDIA_FAILED, { notificationManager.sendNotification(Notification.MEDIA_FAILED, {
event: `Series Request Failed`,
subject: `${series.name}${ subject: `${series.name}${
series.first_air_date series.first_air_date
? ` (${series.first_air_date.slice(0, 4)})` ? ` (${series.first_air_date.slice(0, 4)})`
@ -736,13 +759,14 @@ export class MediaRequest {
media, media,
extra: [ extra: [
{ {
name: 'Seasons', name: 'Requested Seasons',
value: this.seasons value: this.seasons
.map((season) => season.seasonNumber) .map((season) => season.seasonNumber)
.join(', '), .join(', '),
}, },
], ],
request: this, request: this,
notifyAdmin: true,
}); });
}); });
logger.info('Sent request to Sonarr', { label: 'Media Request' }); logger.info('Sent request to Sonarr', { label: 'Media Request' });

@ -1,12 +1,15 @@
import { Notification } from '..'; import { Notification } from '..';
import type Issue from '../../../entity/Issue'; import type Issue from '../../../entity/Issue';
import type Media from '../../../entity/Media'; import IssueComment from '../../../entity/IssueComment';
import Media from '../../../entity/Media';
import { MediaRequest } from '../../../entity/MediaRequest'; import { MediaRequest } from '../../../entity/MediaRequest';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import { NotificationAgentConfig } from '../../settings'; import { NotificationAgentConfig } from '../../settings';
export interface NotificationPayload { export interface NotificationPayload {
event?: string;
subject: string; subject: string;
notifyAdmin: boolean;
notifyUser?: User; notifyUser?: User;
media?: Media; media?: Media;
image?: string; image?: string;
@ -14,6 +17,7 @@ export interface NotificationPayload {
extra?: { name: string; value: string }[]; extra?: { name: string; value: string }[];
request?: MediaRequest; request?: MediaRequest;
issue?: Issue; issue?: Issue;
comment?: IssueComment;
} }
export abstract class BaseAgent<T extends NotificationAgentConfig> { export abstract class BaseAgent<T extends NotificationAgentConfig> {

@ -1,11 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..'; import {
import { IssueStatus, IssueTypeNames } from '../../../constants/issue'; hasNotificationType,
import { MediaType } from '../../../constants/media'; Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentDiscord, NotificationAgentDiscord,
@ -109,9 +111,9 @@ class DiscordAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): DiscordRichEmbed { ): DiscordRichEmbed {
const settings = getSettings(); const { applicationUrl } = getSettings().main;
let color = EmbedColors.DARK_PURPLE;
let color = EmbedColors.DARK_PURPLE;
const fields: Field[] = []; const fields: Field[] = [];
if (payload.request) { if (payload.request) {
@ -120,19 +122,55 @@ class DiscordAgent
value: payload.request.requestedBy.displayName, value: payload.request.requestedBy.displayName,
inline: true, inline: true,
}); });
}
// If payload has an issue attached, push issue specific fields let status = '';
if (payload.issue) { switch (type) {
case Notification.MEDIA_PENDING:
color = EmbedColors.ORANGE;
status = 'Pending Approval';
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
status = 'Processing';
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
status = 'Available';
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
status = 'Declined';
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
status = 'Failed';
break;
}
if (status) {
fields.push({
name: 'Request Status',
value: status,
inline: true,
});
}
} else if (payload.comment) {
fields.push({
name: `Comment from ${payload.comment.user.displayName}`,
value: payload.comment.message,
inline: false,
});
} else if (payload.issue) {
fields.push( fields.push(
{ {
name: 'Created By', name: 'Reported By',
value: payload.issue.createdBy.displayName, value: payload.issue.createdBy.displayName,
inline: true, inline: true,
}, },
{ {
name: 'Issue Type', name: 'Issue Type',
value: IssueTypeNames[payload.issue.issueType], value: IssueTypeName[payload.issue.issueType],
inline: true, inline: true,
}, },
{ {
@ -143,85 +181,35 @@ class DiscordAgent
} }
); );
if (payload.issue.media.mediaType === MediaType.TV) { switch (type) {
fields.push({ case Notification.ISSUE_CREATED:
name: 'Affected Season', case Notification.ISSUE_REOPENED:
value: color = EmbedColors.RED;
payload.issue.problemSeason > 0 break;
? `Season ${payload.issue.problemSeason}` case Notification.ISSUE_COMMENT:
: 'All Seasons', color = EmbedColors.ORANGE;
}); break;
case Notification.ISSUE_RESOLVED:
if (payload.issue.problemSeason > 0) { color = EmbedColors.GREEN;
fields.push({ break;
name: 'Affected Episode',
value:
payload.issue.problemEpisode > 0
? `Episode ${payload.issue.problemEpisode}`
: 'All Episodes',
});
}
} }
} }
switch (type) { for (const extra of payload.extra ?? []) {
case Notification.MEDIA_PENDING: fields.push({
color = EmbedColors.ORANGE; name: extra.name,
fields.push({ value: extra.value,
name: 'Status', inline: true,
value: 'Pending Approval', });
inline: true,
});
break;
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AUTO_APPROVED:
color = EmbedColors.PURPLE;
fields.push({
name: 'Status',
value: 'Processing',
inline: true,
});
break;
case Notification.MEDIA_AVAILABLE:
color = EmbedColors.GREEN;
fields.push({
name: 'Status',
value: 'Available',
inline: true,
});
break;
case Notification.MEDIA_DECLINED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Declined',
inline: true,
});
break;
case Notification.MEDIA_FAILED:
color = EmbedColors.RED;
fields.push({
name: 'Status',
value: 'Failed',
inline: true,
});
break;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
color = EmbedColors.ORANGE;
if (payload.issue && payload.issue.status === IssueStatus.RESOLVED) {
color = EmbedColors.GREEN;
}
break;
} }
const url = const url = applicationUrl
settings.main.applicationUrl && payload.media ? payload.issue
? `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}` ? `${applicationUrl}/issue/${payload.issue.id}`
: undefined; : payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
return { return {
title: payload.subject, title: payload.subject,
@ -229,18 +217,12 @@ class DiscordAgent
description: payload.message, description: payload.message,
color, color,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
author: { author: payload.event
name: settings.main.applicationTitle, ? {
url: settings.main.applicationUrl, name: payload.event,
}, }
fields: [ : undefined,
...fields, fields,
// If we have extra data, map it to fields for discord notifications
...(payload.extra ?? []).map((extra) => ({
name: extra.name,
value: extra.value,
})),
],
thumbnail: { thumbnail: {
url: payload.image, url: payload.image,
}, },
@ -273,54 +255,53 @@ class DiscordAgent
subject: payload.subject, subject: payload.subject,
}); });
let content = undefined; const userMentions: string[] = [];
try { try {
if (payload.notifyUser) { if (payload.notifyUser) {
// Mention user who submitted the request
if ( if (
payload.notifyUser.settings?.hasNotificationType( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.DISCORD, NotificationAgentKey.DISCORD,
type type
) && ) &&
payload.notifyUser.settings?.discordId payload.notifyUser.settings.discordId
) { ) {
content = `<@${payload.notifyUser.settings.discordId}>`; userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
} }
} else { }
// Mention all users with the Manage Requests permission
if (payload.notifyAdmin) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find(); const users = await userRepository.find();
content = users userMentions.push(
.filter( ...users
(user) => .filter(
user.hasPermission(Permission.MANAGE_REQUESTS) && (user) =>
user.settings?.hasNotificationType( user.settings?.hasNotificationType(
NotificationAgentKey.DISCORD, NotificationAgentKey.DISCORD,
type type
) && ) &&
user.settings?.discordId && user.settings.discordId &&
// Check if it's the user's own auto-approved request shouldSendAdminNotification(type, user, payload)
(type !== Notification.MEDIA_AUTO_APPROVED || )
user.id !== payload.request?.requestedBy.id) .map((user) => `<@${user.settings?.discordId}>`)
) );
.map((user) => `<@${user.settings?.discordId}>`)
.join(' ');
} }
await axios.post(settings.options.webhookUrl, { await axios.post(settings.options.webhookUrl, {
username: settings.options.botUsername, username: settings.options.botUsername
? settings.options.botUsername
: getSettings().main.applicationTitle,
avatar_url: settings.options.botAvatarUrl, avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload)], embeds: [this.buildEmbed(type, payload)],
content, content: userMentions.join(' '),
} as DiscordWebhookPayload); } as DiscordWebhookPayload);
return true; return true;
} catch (e) { } catch (e) {
logger.error('Error sending Discord notification', { logger.error('Error sending Discord notification', {
label: 'Notifications', label: 'Notifications',
mentions: content,
type: Notification[type], type: Notification[type],
subject: payload.subject, subject: payload.subject,
errorMessage: e.message, errorMessage: e.message,

@ -1,12 +1,12 @@
import { EmailOptions } from 'email-templates'; import { EmailOptions } from 'email-templates';
import path from 'path'; import path from 'path';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { Notification } from '..'; import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media'; import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import PreparedEmail from '../../email'; import PreparedEmail from '../../email';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentEmail, NotificationAgentEmail,
@ -67,59 +67,34 @@ class EmailAgent
}; };
} }
if (payload.media) { const mediaType = payload.media
let requestType = ''; ? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
if (payload.request) {
let body = ''; let body = '';
switch (type) { switch (type) {
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
requestType = `New ${ body = `A new request for the following ${mediaType} is pending approval:`;
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
body = `A user has requested a new ${
payload.media?.mediaType === MediaType.TV ? 'series' : 'movie'
}!`;
break; break;
case Notification.MEDIA_APPROVED: case Notification.MEDIA_APPROVED:
requestType = `${ body = `Your request for the following ${mediaType} has been approved:`;
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; break;
case Notification.MEDIA_AUTO_APPROVED: case Notification.MEDIA_AUTO_APPROVED:
requestType = `${ body = `A new request for the following ${mediaType} has been automatically approved:`;
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; break;
case Notification.MEDIA_AVAILABLE: case Notification.MEDIA_AVAILABLE:
requestType = `${ body = `Your request for the following ${mediaType} is now available:`;
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; break;
case Notification.MEDIA_DECLINED: case Notification.MEDIA_DECLINED:
requestType = `${ body = `Your request for the following ${mediaType} was declined:`;
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; break;
case Notification.MEDIA_FAILED: case Notification.MEDIA_FAILED:
requestType = `Failed ${ body = `A request for the following ${mediaType} failed to be added to ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' payload.media?.mediaType === MediaType.MOVIE ? 'Radarr' : 'Sonarr'
} 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; break;
} }
@ -133,14 +108,13 @@ class EmailAgent
to: recipientEmail, to: recipientEmail,
}, },
locals: { locals: {
requestType, event: payload.event,
body, body,
mediaName: payload.subject, mediaName: payload.subject,
mediaPlot: payload.message,
mediaExtra: payload.extra ?? [], mediaExtra: payload.extra ?? [],
imageUrl: payload.image, imageUrl: payload.image,
timestamp: new Date().toTimeString(), timestamp: new Date().toTimeString(),
requestedBy: payload.request?.requestedBy.displayName, requestedBy: payload.request.requestedBy.displayName,
actionUrl: applicationUrl actionUrl: applicationUrl
? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}` ? `${applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`
: undefined, : undefined,
@ -150,6 +124,52 @@ class EmailAgent
recipientEmail, recipientEmail,
}, },
}; };
} else if (payload.issue) {
const issueType =
payload.issue && payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue';
let body = '';
switch (type) {
case Notification.ISSUE_CREATED:
body = `A new ${issueType} has been reported by ${payload.issue.createdBy.displayName} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_COMMENT:
body = `${payload.comment?.user.displayName} commented on the ${issueType} for the ${mediaType} ${payload.subject}:`;
break;
case Notification.ISSUE_RESOLVED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was marked as resolved by ${payload.issue.modifiedBy?.displayName}!`;
break;
case Notification.ISSUE_REOPENED:
body = `The ${issueType} for the ${mediaType} ${payload.subject} was reopened by ${payload.issue.modifiedBy?.displayName}.`;
break;
}
return {
template: path.join(__dirname, '../../../templates/email/media-issue'),
message: {
to: recipientEmail,
},
locals: {
event: payload.event,
body,
issueDescription: payload.message,
issueComment: payload.comment?.message,
mediaName: payload.subject,
extra: payload.extra ?? [],
imageUrl: payload.image,
timestamp: new Date().toTimeString(),
actionUrl: applicationUrl
? `${applicationUrl}/issue/${payload.issue.id}`
: undefined,
applicationUrl,
applicationTitle,
recipientName,
recipientEmail,
},
};
} }
return undefined; return undefined;
@ -160,7 +180,6 @@ class EmailAgent
payload: NotificationPayload payload: NotificationPayload
): Promise<boolean> { ): Promise<boolean> {
if (payload.notifyUser) { if (payload.notifyUser) {
// Send notification to the user who submitted the request
if ( if (
!payload.notifyUser.settings || !payload.notifyUser.settings ||
// Check if user has email notifications enabled and fallback to true if undefined // Check if user has email notifications enabled and fallback to true if undefined
@ -203,8 +222,9 @@ class EmailAgent
return false; return false;
} }
} }
} else { }
// Send notifications to all users with the Manage Requests permission
if (payload.notifyAdmin) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find(); const users = await userRepository.find();
@ -212,7 +232,6 @@ class EmailAgent
users users
.filter( .filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
(!user.settings || (!user.settings ||
// Check if user has email notifications enabled and fallback to true if undefined // Check if user has email notifications enabled and fallback to true if undefined
// since email should default to true // since email should default to true
@ -221,9 +240,7 @@ class EmailAgent
type type
) ?? ) ??
true)) && true)) &&
// Check if it's the user's own auto-approved request shouldSendAdminNotification(type, user, payload)
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) )
.map(async (user) => { .map(async (user) => {
logger.debug('Sending email notification', { logger.debug('Sending email notification', {

@ -1,5 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media'; import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentLunaSea } from '../../settings'; import { getSettings, NotificationAgentLunaSea } from '../../settings';
@ -22,17 +23,17 @@ class LunaSeaAgent
private buildPayload(type: Notification, payload: NotificationPayload) { private buildPayload(type: Notification, payload: NotificationPayload) {
return { return {
notification_type: Notification[type], notification_type: Notification[type],
event: payload.event,
subject: payload.subject, subject: payload.subject,
message: payload.message, message: payload.message,
image: payload.image ?? null, image: payload.image ?? null,
email: payload.notifyUser?.email, email: payload.notifyUser?.email,
username: payload.notifyUser?.username, username: payload.notifyUser?.displayName,
avatar: payload.notifyUser?.avatar, avatar: payload.notifyUser?.avatar,
media: payload.media media: payload.media
? { ? {
media_type: payload.media.mediaType, media_type: payload.media.mediaType,
tmdbId: payload.media.tmdbId, tmdbId: payload.media.tmdbId,
imdbId: payload.media.imdbId,
tvdbId: payload.media.tvdbId, tvdbId: payload.media.tvdbId,
status: MediaStatus[payload.media.status], status: MediaStatus[payload.media.status],
status4k: MediaStatus[payload.media.status4k], status4k: MediaStatus[payload.media.status4k],
@ -47,6 +48,24 @@ class LunaSeaAgent
requestedBy_avatar: payload.request.requestedBy.avatar, requestedBy_avatar: payload.request.requestedBy.avatar,
} }
: null, : null,
issue: payload.issue
? {
issue_id: payload.issue.id,
issue_type: IssueType[payload.issue.issueType],
issue_status: IssueStatus[payload.issue.status],
createdBy_email: payload.issue.createdBy.email,
createdBy_username: payload.issue.createdBy.displayName,
createdBy_avatar: payload.issue.createdBy.avatar,
}
: null,
comment: payload.comment
? {
comment_message: payload.comment.message,
commentedBy_email: payload.comment.user.email,
commentedBy_username: payload.comment.user.displayName,
commentedBy_avatar: payload.comment.user.avatar,
}
: null,
}; };
} }

@ -1,10 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..'; import {
import { MediaType } from '../../../constants/media'; hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentKey, NotificationAgentKey,
@ -40,94 +43,55 @@ class PushbulletAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): PushbulletPayload { ): PushbulletPayload {
let messageTitle = ''; const title = payload.event
let message = ''; ? `${payload.event} - ${payload.subject}`
: payload.subject;
const title = payload.subject; let body = payload.message ?? '';
const plot = payload.message;
const username = payload.request?.requestedBy.displayName; if (payload.request) {
body += `\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;
}
switch (type) { if (status) {
case Notification.MEDIA_PENDING: body += `\nRequest Status: ${status}`;
messageTitle = `New ${ }
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' } else if (payload.comment) {
} Request`; body += `\n\nComment from ${payload.comment.user.displayName}:\n${payload.comment.message}`;
message += `${title}`; } else if (payload.issue) {
if (plot) { body += `\n\nReported By: ${payload.issue.createdBy.displayName}`;
message += `\n\n${plot}`; body += `\nIssue Type: ${IssueTypeName[payload.issue.issueType]}`;
} body += `\nIssue Status: ${
message += `\n\nRequested By: ${username}`; payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
message += `\nStatus: Pending Approval`; }`;
break;
case Notification.MEDIA_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AUTO_APPROVED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Automatically Approved`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Processing`;
break;
case Notification.MEDIA_AVAILABLE:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Available`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Declined`;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `${title}`;
if (plot) {
message += `\n\n${plot}`;
}
message += `\n\nRequested By: ${username}`;
message += `\nStatus: Failed`;
break;
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `${plot}`;
break;
} }
for (const extra of payload.extra ?? []) { for (const extra of payload.extra ?? []) {
message += `\n${extra.name}: ${extra.value}`; body += `\n${extra.name}: ${extra.value}`;
} }
return { return {
type: 'note', type: 'note',
title: messageTitle, title,
body: message, body,
}; };
} }
@ -171,7 +135,6 @@ class PushbulletAgent
} }
if (payload.notifyUser) { if (payload.notifyUser) {
// Send notification to the user who submitted the request
if ( if (
payload.notifyUser.settings?.hasNotificationType( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.PUSHBULLET, NotificationAgentKey.PUSHBULLET,
@ -207,8 +170,9 @@ class PushbulletAgent
return false; return false;
} }
} }
} else { }
// Send notifications to all users with the Manage Requests permission
if (payload.notifyAdmin) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find(); const users = await userRepository.find();
@ -216,14 +180,10 @@ class PushbulletAgent
users users
.filter( .filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType( user.settings?.hasNotificationType(
NotificationAgentKey.PUSHBULLET, NotificationAgentKey.PUSHBULLET,
type type
) && ) && shouldSendAdminNotification(type, user, payload)
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) )
.map(async (user) => { .map(async (user) => {
if ( if (

@ -1,10 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..'; import {
import { MediaType } from '../../../constants/media'; hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentKey, NotificationAgentKey,
@ -45,103 +48,77 @@ class PushoverAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Partial<PushoverPayload> { ): Partial<PushoverPayload> {
const settings = getSettings(); const { applicationUrl, applicationTitle } = getSettings().main;
let messageTitle = '';
let message = ''; const title = payload.event ?? payload.subject;
let url: string | undefined; let message = payload.event ? `<b>${payload.subject}</b>` : '';
let url_title: string | undefined;
let priority = 0; let priority = 0;
const title = payload.subject; if (payload.message) {
const plot = payload.message; message += `<small>${message ? '\n' : ''}${payload.message}</small>`;
const username = payload.request?.requestedBy.displayName; }
switch (type) { if (payload.request) {
case Notification.MEDIA_PENDING: message += `<small>\n\n<b>Requested By:</b> ${payload.request.requestedBy.displayName}</small>`;
messageTitle = `New ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' let status = '';
} Request`; switch (type) {
message += `<b>${title}</b>`; case Notification.MEDIA_PENDING:
if (plot) { status = 'Pending Approval';
message += `<small>\n${plot}</small>`; break;
} case Notification.MEDIA_APPROVED:
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`; case Notification.MEDIA_AUTO_APPROVED:
message += `<small>\n\n<b>Status</b>\nPending Approval</small>`; status = 'Processing';
break; break;
case Notification.MEDIA_APPROVED: case Notification.MEDIA_AVAILABLE:
messageTitle = `${ status = 'Available';
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' break;
} Request Approved`; case Notification.MEDIA_DECLINED:
message += `<b>${title}</b>`; status = 'Declined';
if (plot) { priority = 1;
message += `<small>\n${plot}</small>`; break;
} case Notification.MEDIA_FAILED:
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`; status = 'Failed';
message += `<small>\n\n<b>Status</b>\nProcessing</small>`; priority = 1;
break; break;
case Notification.MEDIA_AUTO_APPROVED: }
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' if (status) {
} Request Automatically Approved`; message += `<small>\n<b>Request Status:</b> ${status}</small>`;
message += `<b>${title}</b>`; }
if (plot) { } else if (payload.comment) {
message += `<small>\n${plot}</small>`; message += `<small>\n\n<b>Comment from ${payload.comment.user.displayName}:</b> ${payload.comment.message}</small>`;
} } else if (payload.issue) {
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`; message += `<small>\n\n<b>Reported By:</b> ${payload.issue.createdBy.displayName}</small>`;
message += `<small>\n\n<b>Status</b>\nProcessing</small>`; message += `<small>\n<b>Issue Type:</b> ${
break; IssueTypeName[payload.issue.issueType]
case Notification.MEDIA_AVAILABLE: }</small>`;
messageTitle = `${ message += `<small>\n<b>Issue Status:</b> ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
} Now Available`; }</small>`;
message += `<b>${title}</b>`;
if (plot) { if (type === Notification.ISSUE_CREATED) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nAvailable</small>`;
break;
case Notification.MEDIA_DECLINED:
messageTitle = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nDeclined</small>`;
priority = 1;
break;
case Notification.MEDIA_FAILED:
messageTitle = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
message += `<b>${title}</b>`;
if (plot) {
message += `<small>\n${plot}</small>`;
}
message += `<small>\n\n<b>Requested By</b>\n${username}</small>`;
message += `<small>\n\n<b>Status</b>\nFailed</small>`;
priority = 1; priority = 1;
break; }
case Notification.TEST_NOTIFICATION:
messageTitle = 'Test Notification';
message += `<small>${plot}</small>`;
break;
} }
for (const extra of payload.extra ?? []) { for (const extra of payload.extra ?? []) {
message += `<small>\n\n<b>${extra.name}</b>\n${extra.value}</small>`; message += `<small>\n<b>${extra.name}:</b> ${extra.value}</small>`;
} }
if (settings.main.applicationUrl && payload.media) { const url = applicationUrl
url = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; ? payload.issue
url_title = `Open in ${settings.main.applicationTitle}`; ? `${applicationUrl}/issue/${payload.issue.id}`
} : payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
const url_title = url
? `View ${payload.issue ? 'Issue' : 'Media'} in ${applicationTitle}`
: undefined;
return { return {
title: messageTitle, title,
message, message,
url, url,
url_title, url_title,
@ -191,7 +168,6 @@ class PushoverAgent
} }
if (payload.notifyUser) { if (payload.notifyUser) {
// Send notification to the user who submitted the request
if ( if (
payload.notifyUser.settings?.hasNotificationType( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.PUSHOVER, NotificationAgentKey.PUSHOVER,
@ -230,8 +206,9 @@ class PushoverAgent
return false; return false;
} }
} }
} else { }
// Send notifications to all users with the Manage Requests permission
if (payload.notifyAdmin) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find(); const users = await userRepository.find();
@ -239,14 +216,10 @@ class PushoverAgent
users users
.filter( .filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType( user.settings?.hasNotificationType(
NotificationAgentKey.PUSHOVER, NotificationAgentKey.PUSHOVER,
type type
) && ) && shouldSendAdminNotification(type, user, payload)
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) )
.map(async (user) => { .map(async (user) => {
if ( if (

@ -1,6 +1,6 @@
import axios from 'axios'; import axios from 'axios';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { MediaType } from '../../../constants/media'; import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentSlack } from '../../settings'; import { getSettings, NotificationAgentSlack } from '../../settings';
import { BaseAgent, NotificationAgent, NotificationPayload } from './agent'; import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
@ -19,9 +19,10 @@ interface TextItem {
interface Element { interface Element {
type: 'button'; type: 'button';
text?: TextItem; text?: TextItem;
value: string; action_id: string;
url: string; url?: string;
action_id: 'button-action'; value?: string;
style?: 'primary' | 'danger';
} }
interface EmbedBlock { interface EmbedBlock {
@ -34,7 +35,7 @@ interface EmbedBlock {
image_url: string; image_url: string;
alt_text: string; alt_text: string;
}; };
elements?: Element[]; elements?: (Element | TextItem)[];
} }
interface SlackBlockEmbed { interface SlackBlockEmbed {
@ -59,9 +60,7 @@ class SlackAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): SlackBlockEmbed { ): SlackBlockEmbed {
const settings = getSettings(); const { applicationUrl, applicationTitle } = getSettings().main;
let header = '';
let actionUrl: string | undefined;
const fields: EmbedField[] = []; const fields: EmbedField[] = [];
@ -70,66 +69,55 @@ class SlackAgent
type: 'mrkdwn', type: 'mrkdwn',
text: `*Requested By*\n${payload.request.requestedBy.displayName}`, text: `*Requested By*\n${payload.request.requestedBy.displayName}`,
}); });
}
switch (type) { let status = '';
case Notification.MEDIA_PENDING: switch (type) {
header = `New ${ case Notification.MEDIA_PENDING:
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' status = 'Pending Approval';
} Request`; break;
fields.push({ case Notification.MEDIA_APPROVED:
type: 'mrkdwn', case Notification.MEDIA_AUTO_APPROVED:
text: '*Status*\nPending Approval', status = 'Processing';
}); break;
break; case Notification.MEDIA_AVAILABLE:
case Notification.MEDIA_APPROVED: status = 'Available';
header = `${ break;
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' case Notification.MEDIA_DECLINED:
} Request Approved`; status = 'Declined';
break;
case Notification.MEDIA_FAILED:
status = 'Failed';
break;
}
if (status) {
fields.push({ fields.push({
type: 'mrkdwn', type: 'mrkdwn',
text: '*Status*\nProcessing', text: `*Request Status*\n${status}`,
}); });
break; }
case Notification.MEDIA_AUTO_APPROVED: } else if (payload.comment) {
header = `${ fields.push({
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' type: 'mrkdwn',
} Request Automatically Approved`; text: `*Comment from ${payload.comment.user.displayName}*\n${payload.comment.message}`,
fields.push({ });
type: 'mrkdwn', } else if (payload.issue) {
text: '*Status*\nProcessing', fields.push(
}); {
break;
case Notification.MEDIA_AVAILABLE:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Now Available`;
fields.push({
type: 'mrkdwn', type: 'mrkdwn',
text: '*Status*\nAvailable', text: `*Reported By*\n${payload.issue.createdBy.displayName}`,
}); },
break; {
case Notification.MEDIA_DECLINED:
header = `${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined`;
fields.push({
type: 'mrkdwn', type: 'mrkdwn',
text: '*Status*\nDeclined', text: `*Issue Type*\n${IssueTypeName[payload.issue.issueType]}`,
}); },
break; {
case Notification.MEDIA_FAILED:
header = `Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request`;
fields.push({
type: 'mrkdwn', type: 'mrkdwn',
text: '*Status*\nFailed', text: `*Issue Status*\n${
}); payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
break; }`,
case Notification.TEST_NOTIFICATION: }
header = 'Test Notification'; );
break;
} }
for (const extra of payload.extra ?? []) { for (const extra of payload.extra ?? []) {
@ -139,30 +127,28 @@ class SlackAgent
}); });
} }
if (settings.main.applicationUrl && payload.media) { const blocks: EmbedBlock[] = [];
actionUrl = `${settings.main.applicationUrl}/${payload.media?.mediaType}/${payload.media?.tmdbId}`;
}
const blocks: EmbedBlock[] = [
{
type: 'header',
text: {
type: 'plain_text',
text: header,
},
},
];
if (type !== Notification.TEST_NOTIFICATION) { if (payload.event) {
blocks.push({ blocks.push({
type: 'section', type: 'context',
text: { elements: [
type: 'mrkdwn', {
text: `*${payload.subject}*`, type: 'mrkdwn',
}, text: `*${payload.event}*`,
},
],
}); });
} }
blocks.push({
type: 'header',
text: {
type: 'plain_text',
text: payload.subject,
},
});
if (payload.message) { if (payload.message) {
blocks.push({ blocks.push({
type: 'section', type: 'section',
@ -183,30 +169,31 @@ class SlackAgent
if (fields.length > 0) { if (fields.length > 0) {
blocks.push({ blocks.push({
type: 'section', type: 'section',
fields: [ fields,
...fields,
...(payload.extra ?? []).map(
(extra): EmbedField => ({
type: 'mrkdwn',
text: `*${extra.name}*\n${extra.value}`,
})
),
],
}); });
} }
if (actionUrl) { const url = applicationUrl
? payload.issue
? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
blocks.push({ blocks.push({
type: 'actions', type: 'actions',
elements: [ elements: [
{ {
action_id: 'button-action', action_id: 'open-in-overseerr',
type: 'button', type: 'button',
url: actionUrl, url,
value: 'open_overseerr',
text: { text: {
type: 'plain_text', type: 'plain_text',
text: `Open in ${settings.main.applicationTitle}`, text: `View ${
payload.issue ? 'Issue' : 'Media'
} in ${applicationTitle}`,
}, },
}, },
], ],

@ -1,10 +1,13 @@
import axios from 'axios'; import axios from 'axios';
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import { hasNotificationType, Notification } from '..'; import {
import { MediaType } from '../../../constants/media'; hasNotificationType,
Notification,
shouldSendAdminNotification,
} from '..';
import { IssueStatus, IssueTypeName } from '../../../constants/issue';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentKey, NotificationAgentKey,
@ -61,95 +64,74 @@ class TelegramAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): Partial<TelegramMessagePayload | TelegramPhotoPayload> { ): Partial<TelegramMessagePayload | TelegramPhotoPayload> {
const settings = getSettings(); const { applicationUrl, applicationTitle } = getSettings().main;
let message = '';
const title = this.escapeText(payload.subject);
const plot = this.escapeText(payload.message);
const user = this.escapeText(payload.request?.requestedBy.displayName);
const applicationTitle = this.escapeText(settings.main.applicationTitle);
/* eslint-disable no-useless-escape */ /* eslint-disable no-useless-escape */
switch (type) { let message = `\*${this.escapeText(
case Notification.MEDIA_PENDING: payload.event ? `${payload.event} - ${payload.subject}` : payload.subject
message += `\*New ${ )}\*`;
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' if (payload.message) {
} Request\*`; message += `\n${this.escapeText(payload.message)}`;
message += `\n\n\*${title}\*`; }
if (plot) {
message += `\n${plot}`; if (payload.request) {
} message += `\n\n\*Requested By:\* ${this.escapeText(
message += `\n\n\*Requested By\*\n${user}`; payload.request?.requestedBy.displayName
message += `\n\n\*Status\*\nPending Approval`; )}`;
break;
case Notification.MEDIA_APPROVED: let status = '';
message += `\*${ switch (type) {
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' case Notification.MEDIA_PENDING:
} Request Approved\*`; status = 'Pending Approval';
message += `\n\n\*${title}\*`; break;
if (plot) { case Notification.MEDIA_APPROVED:
message += `\n${plot}`; case Notification.MEDIA_AUTO_APPROVED:
} status = 'Processing';
message += `\n\n\*Requested By\*\n${user}`; break;
message += `\n\n\*Status\*\nProcessing`; case Notification.MEDIA_AVAILABLE:
break; status = 'Available';
case Notification.MEDIA_AUTO_APPROVED: break;
message += `\*${ case Notification.MEDIA_DECLINED:
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' status = 'Declined';
} Request Automatically Approved\*`; break;
message += `\n\n\*${title}\*`; case Notification.MEDIA_FAILED:
if (plot) { status = 'Failed';
message += `\n${plot}`; break;
} }
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nProcessing`; if (status) {
break; message += `\n\*Request Status:\* ${status}`;
case Notification.MEDIA_AVAILABLE: }
message += `\*${ } else if (payload.comment) {
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie' message += `\n\n\*Comment from ${this.escapeText(
} Now Available\*`; payload.comment.user.displayName
message += `\n\n\*${title}\*`; )}:\* ${this.escapeText(payload.comment.message)}`;
if (plot) { } else if (payload.issue) {
message += `\n${plot}`; message += `\n\n\*Reported By:\* ${this.escapeText(
} payload.issue.createdBy.displayName
message += `\n\n\*Requested By\*\n${user}`; )}`;
message += `\n\n\*Status\*\nAvailable`; message += `\n\*Issue Type:\* ${IssueTypeName[payload.issue.issueType]}`;
break; message += `\n\*Issue Status:\* ${
case Notification.MEDIA_DECLINED: payload.issue.status === IssueStatus.OPEN ? 'Open' : 'Resolved'
message += `\*${ }`;
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request Declined\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nDeclined`;
break;
case Notification.MEDIA_FAILED:
message += `\*Failed ${
payload.media?.mediaType === MediaType.TV ? 'Series' : 'Movie'
} Request\*`;
message += `\n\n\*${title}\*`;
if (plot) {
message += `\n${plot}`;
}
message += `\n\n\*Requested By\*\n${user}`;
message += `\n\n\*Status\*\nFailed`;
break;
case Notification.TEST_NOTIFICATION:
message += `\*Test Notification\*`;
message += `\n\n${plot}`;
break;
} }
for (const extra of payload.extra ?? []) { for (const extra of payload.extra ?? []) {
message += `\n\n\*${extra.name}\*\n${extra.value}`; message += `\n\*${extra.name}:\* ${extra.value}`;
} }
if (settings.main.applicationUrl && payload.media) { const url = applicationUrl
const actionUrl = `${settings.main.applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`; ? payload.issue
message += `\n\n\[Open in ${applicationTitle}\]\(${actionUrl}\)`; ? `${applicationUrl}/issue/${payload.issue.id}`
: payload.media
? `${applicationUrl}/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined
: undefined;
if (url) {
message += `\n\n\[View ${
payload.issue ? 'Issue' : 'Media'
} in ${this.escapeText(applicationTitle)}\]\(${url}\)`;
} }
/* eslint-enable */ /* eslint-enable */
@ -206,7 +188,6 @@ class TelegramAgent
} }
if (payload.notifyUser) { if (payload.notifyUser) {
// Send notification to the user who submitted the request
if ( if (
payload.notifyUser.settings?.hasNotificationType( payload.notifyUser.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM, NotificationAgentKey.TELEGRAM,
@ -242,8 +223,9 @@ class TelegramAgent
return false; return false;
} }
} }
} else { }
// Send notifications to all users with the Manage Requests permission
if (payload.notifyAdmin) {
const userRepository = getRepository(User); const userRepository = getRepository(User);
const users = await userRepository.find(); const users = await userRepository.find();
@ -251,14 +233,10 @@ class TelegramAgent
users users
.filter( .filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
user.settings?.hasNotificationType( user.settings?.hasNotificationType(
NotificationAgentKey.TELEGRAM, NotificationAgentKey.TELEGRAM,
type type
) && ) && shouldSendAdminNotification(type, user, payload)
// Check if it's the user's own auto-approved request
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
) )
.map(async (user) => { .map(async (user) => {
if ( if (

@ -1,6 +1,7 @@
import axios from 'axios'; import axios from 'axios';
import { get } from 'lodash'; import { get } from 'lodash';
import { hasNotificationType, Notification } from '..'; import { hasNotificationType, Notification } from '..';
import { IssueStatus, IssueType } from '../../../constants/issue';
import { MediaStatus } from '../../../constants/media'; import { MediaStatus } from '../../../constants/media';
import logger from '../../../logger'; import logger from '../../../logger';
import { getSettings, NotificationAgentWebhook } from '../../settings'; import { getSettings, NotificationAgentWebhook } from '../../settings';
@ -13,6 +14,7 @@ type KeyMapFunction = (
const KeyMap: Record<string, string | KeyMapFunction> = { const KeyMap: Record<string, string | KeyMapFunction> = {
notification_type: (_payload, type) => Notification[type], notification_type: (_payload, type) => Notification[type],
event: 'event',
subject: 'subject', subject: 'subject',
message: 'message', message: 'message',
image: 'image', image: 'image',
@ -22,13 +24,12 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_settings_discordId: 'notifyUser.settings.discordId', notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId', notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_tmdbid: 'media.tmdbId', media_tmdbid: 'media.tmdbId',
media_imdbid: 'media.imdbId',
media_tvdbid: 'media.tvdbId', media_tvdbid: 'media.tvdbId',
media_type: 'media.mediaType', media_type: 'media.mediaType',
media_status: (payload) => media_status: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status] : '', payload.media ? MediaStatus[payload.media.status] : '',
media_status4k: (payload) => media_status4k: (payload) =>
payload.media?.status ? MediaStatus[payload.media?.status4k] : '', payload.media ? MediaStatus[payload.media.status4k] : '',
request_id: 'request.id', request_id: 'request.id',
requestedBy_username: 'request.requestedBy.displayName', requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email', requestedBy_email: 'request.requestedBy.email',
@ -36,6 +37,22 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId', requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
requestedBy_settings_telegramChatId: requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId', 'request.requestedBy.settings.telegramChatId',
issue_id: 'issue.id',
issue_type: (payload) =>
payload.issue ? IssueType[payload.issue.issueType] : '',
issue_status: (payload) =>
payload.issue ? IssueStatus[payload.issue.status] : '',
reportedBy_username: 'issue.createdBy.displayName',
reportedBy_email: 'issue.createdBy.email',
reportedBy_avatar: 'issue.createdBy.avatar',
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
comment_message: 'comment.message',
commentedBy_username: 'comment.user.displayName',
commentedBy_email: 'comment.user.email',
commentedBy_avatar: 'comment.user.avatar',
commentedBy_settings_discordId: 'comment.user.settings.discordId',
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
}; };
class WebhookAgent class WebhookAgent
@ -78,6 +95,22 @@ class WebhookAgent
} }
delete finalPayload[key]; delete finalPayload[key];
key = 'request'; key = 'request';
} else if (key === '{{issue}}') {
if (payload.issue) {
finalPayload.issue = finalPayload[key];
} else {
finalPayload.issue = null;
}
delete finalPayload[key];
key = 'issue';
} else if (key === '{{comment}}') {
if (payload.comment) {
finalPayload.comment = finalPayload[key];
} else {
finalPayload.comment = null;
}
delete finalPayload[key];
key = 'comment';
} }
if (typeof finalPayload[key] === 'string') { if (typeof finalPayload[key] === 'string') {

@ -1,11 +1,11 @@
import { getRepository } from 'typeorm'; import { getRepository } from 'typeorm';
import webpush from 'web-push'; import webpush from 'web-push';
import { Notification } from '..'; import { Notification, shouldSendAdminNotification } from '..';
import { IssueType, IssueTypeName } from '../../../constants/issue';
import { MediaType } from '../../../constants/media'; import { MediaType } from '../../../constants/media';
import { User } from '../../../entity/User'; import { User } from '../../../entity/User';
import { UserPushSubscription } from '../../../entity/UserPushSubscription'; import { UserPushSubscription } from '../../../entity/UserPushSubscription';
import logger from '../../../logger'; import logger from '../../../logger';
import { Permission } from '../../permissions';
import { import {
getSettings, getSettings,
NotificationAgentConfig, NotificationAgentConfig,
@ -15,12 +15,11 @@ import { BaseAgent, NotificationAgent, NotificationPayload } from './agent';
interface PushNotificationPayload { interface PushNotificationPayload {
notificationType: string; notificationType: string;
mediaType?: 'movie' | 'tv';
tmdbId?: number;
subject: string; subject: string;
message?: string; message?: string;
image?: string; image?: string;
actionUrl?: string; actionUrl?: string;
actionUrlTitle?: string;
requestId?: number; requestId?: number;
} }
@ -42,97 +41,79 @@ class WebPushAgent
type: Notification, type: Notification,
payload: NotificationPayload payload: NotificationPayload
): PushNotificationPayload { ): PushNotificationPayload {
const mediaType = payload.media
? payload.media.mediaType === MediaType.MOVIE
? 'movie'
: 'series'
: undefined;
const issueType = payload.issue
? payload.issue.issueType !== IssueType.OTHER
? `${IssueTypeName[payload.issue.issueType].toLowerCase()} issue`
: 'issue'
: undefined;
let message: string | undefined;
switch (type) { switch (type) {
case Notification.TEST_NOTIFICATION: case Notification.TEST_NOTIFICATION:
return { message = payload.message;
notificationType: Notification[type], break;
subject: payload.subject,
message: payload.message,
};
case Notification.MEDIA_APPROVED: case Notification.MEDIA_APPROVED:
return { message = `Your ${mediaType} request has been approved.`;
notificationType: Notification[type], break;
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request has been approved.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AUTO_APPROVED: case Notification.MEDIA_AUTO_APPROVED:
return { message = `Automatically approved a new ${mediaType} request from ${payload.request?.requestedBy.displayName}.`;
notificationType: Notification[type], break;
subject: payload.subject,
message: `Automatically approved a new ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request from ${payload.request?.requestedBy.displayName}.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_AVAILABLE: case Notification.MEDIA_AVAILABLE:
return { message = `Your ${mediaType} request is now available!`;
notificationType: Notification[type], break;
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request is now available!`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_DECLINED: case Notification.MEDIA_DECLINED:
return { message = `Your ${mediaType} request was declined.`;
notificationType: Notification[type], break;
subject: payload.subject,
message: `Your ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request was declined.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_FAILED: case Notification.MEDIA_FAILED:
return { message = `Failed to process ${mediaType} request.`;
notificationType: Notification[type], break;
subject: payload.subject,
message: `Failed to process ${
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series'
} request.`,
image: payload.image,
mediaType: payload.media?.mediaType,
tmdbId: payload.media?.tmdbId,
requestId: payload.request?.id,
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`,
};
case Notification.MEDIA_PENDING: case Notification.MEDIA_PENDING:
return { message = `Approval required for a new ${mediaType} request from ${payload.request?.requestedBy.displayName}.`;
notificationType: Notification[type], break;
subject: payload.subject, case Notification.ISSUE_CREATED:
message: `Approval required for new ${ message = `A new ${issueType} was reported by ${payload.issue?.createdBy.displayName}.`;
payload.media?.mediaType === MediaType.MOVIE ? 'movie' : 'series' break;
} request from ${payload.request?.requestedBy.displayName}.`, case Notification.ISSUE_COMMENT:
image: payload.image, message = `${payload.comment?.user.displayName} commented on the ${issueType}.`;
mediaType: payload.media?.mediaType, break;
tmdbId: payload.media?.tmdbId, case Notification.ISSUE_RESOLVED:
requestId: payload.request?.id, message = `The ${issueType} was marked as resolved by ${payload.issue?.modifiedBy?.displayName}!`;
actionUrl: `/${payload.media?.mediaType}/${payload.media?.tmdbId}`, break;
}; case Notification.ISSUE_REOPENED:
message = `The ${issueType} was reopened by ${payload.issue?.modifiedBy?.displayName}.`;
break;
default: default:
return { return {
notificationType: Notification[type], notificationType: Notification[type],
subject: 'Unknown', subject: 'Unknown',
}; };
} }
const actionUrl = payload.issue
? `/issue/${payload.issue.id}`
: payload.media
? `/${payload.media.mediaType}/${payload.media.tmdbId}`
: undefined;
const actionUrlTitle = actionUrl
? `View ${payload.issue ? 'Issue' : 'Media'}`
: undefined;
return {
notificationType: Notification[type],
subject: payload.subject,
message,
image: payload.image,
requestId: payload.request?.id,
actionUrl,
actionUrlTitle,
};
} }
public shouldSend(): boolean { public shouldSend(): boolean {
@ -151,7 +132,7 @@ class WebPushAgent
const userPushSubRepository = getRepository(UserPushSubscription); const userPushSubRepository = getRepository(UserPushSubscription);
const settings = getSettings(); const settings = getSettings();
let pushSubs: UserPushSubscription[] = []; const pushSubs: UserPushSubscription[] = [];
const mainUser = await userRepository.findOne({ where: { id: 1 } }); const mainUser = await userRepository.findOne({ where: { id: 1 } });
@ -169,13 +150,14 @@ class WebPushAgent
where: { user: payload.notifyUser.id }, where: { user: payload.notifyUser.id },
}); });
pushSubs = notifySubs; pushSubs.push(...notifySubs);
} else if (!payload.notifyUser) { }
if (payload.notifyAdmin) {
const users = await userRepository.find(); const users = await userRepository.find();
const manageUsers = users.filter( const manageUsers = users.filter(
(user) => (user) =>
user.hasPermission(Permission.MANAGE_REQUESTS) &&
// Check if user has webpush notifications enabled and fallback to true if undefined // Check if user has webpush notifications enabled and fallback to true if undefined
// since web push should default to true // since web push should default to true
(user.settings?.hasNotificationType( (user.settings?.hasNotificationType(
@ -183,9 +165,7 @@ class WebPushAgent
type type
) ?? ) ??
true) && true) &&
// Check if it's the user's own auto-approved request shouldSendAdminNotification(type, user, payload)
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !== payload.request?.requestedBy.id)
); );
const allSubs = await userPushSubRepository const allSubs = await userPushSubRepository
@ -196,7 +176,7 @@ class WebPushAgent
}) })
.getMany(); .getMany();
pushSubs = allSubs; pushSubs.push(...allSubs);
} }
if (mainUser && pushSubs.length > 0) { if (mainUser && pushSubs.length > 0) {

@ -1,4 +1,6 @@
import { User } from '../../entity/User';
import logger from '../../logger'; import logger from '../../logger';
import { Permission } from '../permissions';
import type { NotificationAgent, NotificationPayload } from './agents/agent'; import type { NotificationAgent, NotificationPayload } from './agents/agent';
export enum Notification { export enum Notification {
@ -13,6 +15,7 @@ export enum Notification {
ISSUE_CREATED = 256, ISSUE_CREATED = 256,
ISSUE_COMMENT = 512, ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024, ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
} }
export const hasNotificationType = ( export const hasNotificationType = (
@ -41,6 +44,50 @@ export const hasNotificationType = (
return !!(value & total); return !!(value & total);
}; };
export const getAdminPermission = (type: Notification): Permission => {
switch (type) {
case Notification.MEDIA_PENDING:
case Notification.MEDIA_APPROVED:
case Notification.MEDIA_AVAILABLE:
case Notification.MEDIA_FAILED:
case Notification.MEDIA_DECLINED:
case Notification.MEDIA_AUTO_APPROVED:
return Permission.MANAGE_REQUESTS;
case Notification.ISSUE_CREATED:
case Notification.ISSUE_COMMENT:
case Notification.ISSUE_RESOLVED:
case Notification.ISSUE_REOPENED:
return Permission.MANAGE_ISSUES;
default:
return Permission.ADMIN;
}
};
export const shouldSendAdminNotification = (
type: Notification,
user: User,
payload: NotificationPayload
): boolean => {
return (
user.id !== payload.notifyUser?.id &&
user.hasPermission(getAdminPermission(type)) &&
// Check if the user submitted this request (on behalf of themself OR another user)
(type !== Notification.MEDIA_AUTO_APPROVED ||
user.id !==
(payload.request?.modifiedBy ?? payload.request?.requestedBy)?.id) &&
// Check if the user created this issue
(type !== Notification.ISSUE_CREATED ||
user.id !== payload.issue?.createdBy.id) &&
// Check if the user submitted this issue comment
(type !== Notification.ISSUE_COMMENT ||
user.id !== payload.comment?.user.id) &&
// Check if the user resolved/reopened this issue
((type !== Notification.ISSUE_RESOLVED &&
type !== Notification.ISSUE_REOPENED) ||
user.id !== payload.issue?.modifiedBy?.id)
);
};
class NotificationManager { class NotificationManager {
private activeAgents: NotificationAgent[] = []; private activeAgents: NotificationAgent[] = [];

@ -350,7 +350,7 @@ class Settings {
options: { options: {
webhookUrl: '', webhookUrl: '',
jsonPayload: jsonPayload:
'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJzdWJqZWN0XCI6IFwie3tzdWJqZWN0fX1cIixcbiAgICBcIm1lc3NhZ2VcIjogXCJ7e21lc3NhZ2V9fVwiLFxuICAgIFwiaW1hZ2VcIjogXCJ7e2ltYWdlfX1cIixcbiAgICBcImVtYWlsXCI6IFwie3tub3RpZnl1c2VyX2VtYWlsfX1cIixcbiAgICBcInVzZXJuYW1lXCI6IFwie3tub3RpZnl1c2VyX3VzZXJuYW1lfX1cIixcbiAgICBcImF2YXRhclwiOiBcInt7bm90aWZ5dXNlcl9hdmF0YXJ9fVwiLFxuICAgIFwie3ttZWRpYX19XCI6IHtcbiAgICAgICAgXCJtZWRpYV90eXBlXCI6IFwie3ttZWRpYV90eXBlfX1cIixcbiAgICAgICAgXCJ0bWRiSWRcIjogXCJ7e21lZGlhX3RtZGJpZH19XCIsXG4gICAgICAgIFwiaW1kYklkXCI6IFwie3ttZWRpYV9pbWRiaWR9fVwiLFxuICAgICAgICBcInR2ZGJJZFwiOiBcInt7bWVkaWFfdHZkYmlkfX1cIixcbiAgICAgICAgXCJzdGF0dXNcIjogXCJ7e21lZGlhX3N0YXR1c319XCIsXG4gICAgICAgIFwic3RhdHVzNGtcIjogXCJ7e21lZGlhX3N0YXR1czRrfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW10sXG4gICAgXCJ7e3JlcXVlc3R9fVwiOiB7XG4gICAgICAgIFwicmVxdWVzdF9pZFwiOiBcInt7cmVxdWVzdF9pZH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfZW1haWxcIjogXCJ7e3JlcXVlc3RlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV91c2VybmFtZVwiOiBcInt7cmVxdWVzdGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2F2YXRhclwiOiBcInt7cmVxdWVzdGVkQnlfYXZhdGFyfX1cIlxuICAgIH1cbn0i', 'IntcbiAgICBcIm5vdGlmaWNhdGlvbl90eXBlXCI6IFwie3tub3RpZmljYXRpb25fdHlwZX19XCIsXG4gICAgXCJldmVudFwiOiBcInt7ZXZlbnR9fVwiLFxuICAgIFwic3ViamVjdFwiOiBcInt7c3ViamVjdH19XCIsXG4gICAgXCJtZXNzYWdlXCI6IFwie3ttZXNzYWdlfX1cIixcbiAgICBcImltYWdlXCI6IFwie3tpbWFnZX19XCIsXG4gICAgXCJ7e21lZGlhfX1cIjoge1xuICAgICAgICBcIm1lZGlhX3R5cGVcIjogXCJ7e21lZGlhX3R5cGV9fVwiLFxuICAgICAgICBcInRtZGJJZFwiOiBcInt7bWVkaWFfdG1kYmlkfX1cIixcbiAgICAgICAgXCJ0dmRiSWRcIjogXCJ7e21lZGlhX3R2ZGJpZH19XCIsXG4gICAgICAgIFwic3RhdHVzXCI6IFwie3ttZWRpYV9zdGF0dXN9fVwiLFxuICAgICAgICBcInN0YXR1czRrXCI6IFwie3ttZWRpYV9zdGF0dXM0a319XCJcbiAgICB9LFxuICAgIFwie3tyZXF1ZXN0fX1cIjoge1xuICAgICAgICBcInJlcXVlc3RfaWRcIjogXCJ7e3JlcXVlc3RfaWR9fVwiLFxuICAgICAgICBcInJlcXVlc3RlZEJ5X2VtYWlsXCI6IFwie3tyZXF1ZXN0ZWRCeV9lbWFpbH19XCIsXG4gICAgICAgIFwicmVxdWVzdGVkQnlfdXNlcm5hbWVcIjogXCJ7e3JlcXVlc3RlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXF1ZXN0ZWRCeV9hdmF0YXJcIjogXCJ7e3JlcXVlc3RlZEJ5X2F2YXRhcn19XCJcbiAgICB9LFxuICAgIFwie3tpc3N1ZX19XCI6IHtcbiAgICAgICAgXCJpc3N1ZV9pZFwiOiBcInt7aXNzdWVfaWR9fVwiLFxuICAgICAgICBcImlzc3VlX3R5cGVcIjogXCJ7e2lzc3VlX3R5cGV9fVwiLFxuICAgICAgICBcImlzc3VlX3N0YXR1c1wiOiBcInt7aXNzdWVfc3RhdHVzfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2VtYWlsXCI6IFwie3tyZXBvcnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X3VzZXJuYW1lXCI6IFwie3tyZXBvcnRlZEJ5X3VzZXJuYW1lfX1cIixcbiAgICAgICAgXCJyZXBvcnRlZEJ5X2F2YXRhclwiOiBcInt7cmVwb3J0ZWRCeV9hdmF0YXJ9fVwiXG4gICAgfSxcbiAgICBcInt7Y29tbWVudH19XCI6IHtcbiAgICAgICAgXCJjb21tZW50X21lc3NhZ2VcIjogXCJ7e2NvbW1lbnRfbWVzc2FnZX19XCIsXG4gICAgICAgIFwiY29tbWVudGVkQnlfZW1haWxcIjogXCJ7e2NvbW1lbnRlZEJ5X2VtYWlsfX1cIixcbiAgICAgICAgXCJjb21tZW50ZWRCeV91c2VybmFtZVwiOiBcInt7Y29tbWVudGVkQnlfdXNlcm5hbWV9fVwiLFxuICAgICAgICBcImNvbW1lbnRlZEJ5X2F2YXRhclwiOiBcInt7Y29tbWVudGVkQnlfYXZhdGFyfX1cIlxuICAgIH0sXG4gICAgXCJ7e2V4dHJhfX1cIjogW11cbn0i',
}, },
}, },
webpush: { webpush: {

@ -277,6 +277,7 @@ issueRoutes.post<{ issueId: string; status: string }, Issue>(
} }
issue.status = newStatus; issue.status = newStatus;
issue.modifiedBy = req.user;
await issueRepository.save(issue); await issueRepository.save(issue);

@ -1,5 +1,7 @@
import { Router } from 'express'; import { Router } from 'express';
import { User } from '../../entity/User';
import { Notification } from '../../lib/notifications'; import { Notification } from '../../lib/notifications';
import { NotificationAgent } from '../../lib/notifications/agents/agent';
import DiscordAgent from '../../lib/notifications/agents/discord'; import DiscordAgent from '../../lib/notifications/agents/discord';
import EmailAgent from '../../lib/notifications/agents/email'; import EmailAgent from '../../lib/notifications/agents/email';
import LunaSeaAgent from '../../lib/notifications/agents/lunasea'; import LunaSeaAgent from '../../lib/notifications/agents/lunasea';
@ -13,6 +15,14 @@ import { getSettings } from '../../lib/settings';
const notificationRoutes = Router(); const notificationRoutes = Router();
const sendTestNotification = async (agent: NotificationAgent, user: User) =>
await agent.send(Notification.TEST_NOTIFICATION, {
notifyAdmin: false,
notifyUser: user,
subject: 'Test Notification',
message: 'Check check, 1, 2, 3. Are we coming in clear?',
});
notificationRoutes.get('/discord', (_req, res) => { notificationRoutes.get('/discord', (_req, res) => {
const settings = getSettings(); const settings = getSettings();
@ -37,14 +47,7 @@ notificationRoutes.post('/discord/test', async (req, res, next) => {
} }
const discordAgent = new DiscordAgent(req.body); const discordAgent = new DiscordAgent(req.body);
if ( if (await sendTestNotification(discordAgent, req.user)) {
await discordAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -78,14 +81,7 @@ notificationRoutes.post('/slack/test', async (req, res, next) => {
} }
const slackAgent = new SlackAgent(req.body); const slackAgent = new SlackAgent(req.body);
if ( if (await sendTestNotification(slackAgent, req.user)) {
await 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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -119,14 +115,7 @@ notificationRoutes.post('/telegram/test', async (req, res, next) => {
} }
const telegramAgent = new TelegramAgent(req.body); const telegramAgent = new TelegramAgent(req.body);
if ( if (await sendTestNotification(telegramAgent, req.user)) {
await telegramAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -160,14 +149,7 @@ notificationRoutes.post('/pushbullet/test', async (req, res, next) => {
} }
const pushbulletAgent = new PushbulletAgent(req.body); const pushbulletAgent = new PushbulletAgent(req.body);
if ( if (await sendTestNotification(pushbulletAgent, req.user)) {
await pushbulletAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -201,14 +183,7 @@ notificationRoutes.post('/pushover/test', async (req, res, next) => {
} }
const pushoverAgent = new PushoverAgent(req.body); const pushoverAgent = new PushoverAgent(req.body);
if ( if (await sendTestNotification(pushoverAgent, req.user)) {
await pushoverAgent.send(Notification.TEST_NOTIFICATION, {
notifyUser: req.user,
subject: 'Test Notification',
message:
'This is a test notification! Check check, 1, 2, 3. Are we coming in clear?',
})
) {
return res.status(204).send(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -242,14 +217,7 @@ notificationRoutes.post('/email/test', async (req, res, next) => {
} }
const emailAgent = new EmailAgent(req.body); const emailAgent = new EmailAgent(req.body);
if ( if (await sendTestNotification(emailAgent, req.user)) {
await emailAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -283,14 +251,7 @@ notificationRoutes.post('/webpush/test', async (req, res, next) => {
} }
const webpushAgent = new WebPushAgent(req.body); const webpushAgent = new WebPushAgent(req.body);
if ( if (await sendTestNotification(webpushAgent, req.user)) {
await webpushAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -369,14 +330,7 @@ notificationRoutes.post('/webhook/test', async (req, res, next) => {
}; };
const webhookAgent = new WebhookAgent(testBody); const webhookAgent = new WebhookAgent(testBody);
if ( if (await sendTestNotification(webhookAgent, req.user)) {
await webhookAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({
@ -413,14 +367,7 @@ notificationRoutes.post('/lunasea/test', async (req, res, next) => {
} }
const lunaseaAgent = new LunaSeaAgent(req.body); const lunaseaAgent = new LunaSeaAgent(req.body);
if ( if (await sendTestNotification(lunaseaAgent, req.user)) {
await lunaseaAgent.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(); return res.status(204).send();
} else { } else {
return next({ return next({

@ -1,3 +1,4 @@
import { sortBy } from 'lodash';
import { import {
EntitySubscriberInterface, EntitySubscriberInterface,
EventSubscriber, EventSubscriber,
@ -5,9 +6,12 @@ import {
InsertEvent, InsertEvent,
} from 'typeorm'; } from 'typeorm';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { IssueType, IssueTypeName } from '../constants/issue';
import { MediaType } from '../constants/media'; import { MediaType } from '../constants/media';
import IssueComment from '../entity/IssueComment'; import IssueComment from '../entity/IssueComment';
import Media from '../entity/Media';
import notificationManager, { Notification } from '../lib/notifications'; import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
@EventSubscriber() @EventSubscriber()
export class IssueCommentSubscriber export class IssueCommentSubscriber
@ -18,41 +22,67 @@ export class IssueCommentSubscriber
} }
private async sendIssueCommentNotification(entity: IssueComment) { private async sendIssueCommentNotification(entity: IssueComment) {
const issueCommentRepository = getRepository(IssueComment);
let title: string; let title: string;
let image: string; let image: string;
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
const issuecomment = await issueCommentRepository.findOne({
where: { id: entity.id },
relations: ['issue'],
});
const issue = issuecomment?.issue;
const issue = (
await getRepository(IssueComment).findOne({
where: { id: entity.id },
relations: ['issue'],
})
)?.issue;
if (!issue) { if (!issue) {
return; return;
} }
if (issue.media.mediaType === MediaType.MOVIE) { const media = await getRepository(Media).findOne({
const movie = await tmdb.getMovie({ movieId: issue.media.tmdbId }); where: { id: issue.media.id },
});
if (!media) {
return;
}
title = movie.title; if (media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: media.tmdbId });
title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else {
const tvshow = await tmdb.getTvShow({ tvId: issue.media.tmdbId }); const tvshow = await tmdb.getTvShow({ tvId: media.tmdbId });
title = tvshow.name; title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
} }
notificationManager.sendNotification(Notification.ISSUE_COMMENT, { const [firstComment] = sortBy(issue.comments, 'id');
subject: `New Issue Comment: ${title}`,
message: entity.message, if (entity.id !== firstComment.id) {
issue, // Send notifications to all issue managers
image, notificationManager.sendNotification(Notification.ISSUE_COMMENT, {
notifyUser: event: `New Comment on ${
issue.createdBy.id !== entity.user.id ? issue.createdBy : undefined, issue.issueType !== IssueType.OTHER
}); ? `${IssueTypeName[issue.issueType]} `
: ''
}Issue`,
subject: title,
message: firstComment.message,
comment: entity,
issue,
media,
image,
notifyAdmin: true,
notifyUser:
!issue.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
issue.createdBy.id !== entity.user.id
? issue.createdBy
: undefined,
});
}
} }
public afterInsert(event: InsertEvent<IssueComment>): void { public afterInsert(event: InsertEvent<IssueComment>): void {

@ -1,12 +1,16 @@
import { sortBy } from 'lodash';
import { import {
EntitySubscriberInterface, EntitySubscriberInterface,
EventSubscriber, EventSubscriber,
InsertEvent, InsertEvent,
UpdateEvent,
} from 'typeorm'; } from 'typeorm';
import TheMovieDb from '../api/themoviedb'; import TheMovieDb from '../api/themoviedb';
import { IssueStatus, IssueType, IssueTypeName } from '../constants/issue';
import { MediaType } from '../constants/media'; import { MediaType } from '../constants/media';
import Issue from '../entity/Issue'; import Issue from '../entity/Issue';
import notificationManager, { Notification } from '../lib/notifications'; import notificationManager, { Notification } from '../lib/notifications';
import { Permission } from '../lib/permissions';
@EventSubscriber() @EventSubscriber()
export class IssueSubscriber implements EntitySubscriberInterface<Issue> { export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
@ -14,29 +18,75 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
return Issue; return Issue;
} }
private async sendIssueCreatedNotification(entity: Issue) { private async sendIssueNotification(entity: Issue, type: Notification) {
let title: string; let title: string;
let image: string; let image: string;
const tmdb = new TheMovieDb(); const tmdb = new TheMovieDb();
if (entity.media.mediaType === MediaType.MOVIE) { if (entity.media.mediaType === MediaType.MOVIE) {
const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId }); const movie = await tmdb.getMovie({ movieId: entity.media.tmdbId });
title = movie.title; title = `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`;
} else { } else {
const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId }); const tvshow = await tmdb.getTvShow({ tvId: entity.media.tmdbId });
title = tvshow.name; title = `${tvshow.name}${
tvshow.first_air_date ? ` (${tvshow.first_air_date.slice(0, 4)})` : ''
}`;
image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`; image = `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tvshow.poster_path}`;
} }
const [firstComment] = entity.comments; const [firstComment] = sortBy(entity.comments, 'id');
const extra: { name: string; value: string }[] = [];
notificationManager.sendNotification(Notification.ISSUE_CREATED, { if (entity.media.mediaType === MediaType.TV && entity.problemSeason > 0) {
extra.push({
name: 'Affected Season',
value: entity.problemSeason.toString(),
});
if (entity.problemEpisode > 0) {
extra.push({
name: 'Affected Episode',
value: entity.problemEpisode.toString(),
});
}
}
notificationManager.sendNotification(type, {
event:
type === Notification.ISSUE_CREATED
? `New ${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reported`
: type === Notification.ISSUE_RESOLVED
? `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Resolved`
: `${
entity.issueType !== IssueType.OTHER
? `${IssueTypeName[entity.issueType]} `
: ''
}Issue Reopened`,
subject: title, subject: title,
message: firstComment.message, message: firstComment.message,
issue: entity, issue: entity,
media: entity.media,
image, image,
extra,
notifyAdmin: true,
notifyUser:
!entity.createdBy.hasPermission(Permission.MANAGE_ISSUES) &&
(type === Notification.ISSUE_RESOLVED ||
type === Notification.ISSUE_REOPENED)
? entity.createdBy
: undefined,
}); });
} }
@ -45,6 +95,30 @@ export class IssueSubscriber implements EntitySubscriberInterface<Issue> {
return; return;
} }
this.sendIssueCreatedNotification(event.entity); this.sendIssueNotification(event.entity, Notification.ISSUE_CREATED);
}
public beforeUpdate(event: UpdateEvent<Issue>): void {
if (!event.entity) {
return;
}
if (
event.entity.status === IssueStatus.RESOLVED &&
event.databaseEntity.status !== IssueStatus.RESOLVED
) {
this.sendIssueNotification(
event.entity as Issue,
Notification.ISSUE_RESOLVED
);
} else if (
event.entity.status === IssueStatus.OPEN &&
event.databaseEntity.status !== IssueStatus.OPEN
) {
this.sendIssueNotification(
event.entity as Issue,
Notification.ISSUE_REOPENED
);
}
} }
} }

@ -31,6 +31,8 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
relatedRequests.forEach((request) => { relatedRequests.forEach((request) => {
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: 'Movie Now Available',
notifyAdmin: false,
notifyUser: request.requestedBy, notifyUser: request.requestedBy,
subject: `${movie.title}${ subject: `${movie.title}${
movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : '' movie.release_date ? ` (${movie.release_date.slice(0, 4)})` : ''
@ -42,7 +44,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
}), }),
media: entity, media: entity,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${movie.poster_path}`,
request: request, request,
}); });
}); });
} }
@ -91,6 +93,7 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
); );
const tv = await tmdb.getTvShow({ tvId: entity.tmdbId }); const tv = await tmdb.getTvShow({ tvId: entity.tmdbId });
notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, { notificationManager.sendNotification(Notification.MEDIA_AVAILABLE, {
event: 'Series Now Available',
subject: `${tv.name}${ subject: `${tv.name}${
tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : '' tv.first_air_date ? ` (${tv.first_air_date.slice(0, 4)})` : ''
}`, }`,
@ -99,18 +102,19 @@ export class MediaSubscriber implements EntitySubscriberInterface<Media> {
separator: /\s/, separator: /\s/,
omission: '…', omission: '…',
}), }),
notifyAdmin: false,
notifyUser: request.requestedBy, notifyUser: request.requestedBy,
image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`, image: `https://image.tmdb.org/t/p/w600_and_h900_bestv2${tv.poster_path}`,
media: entity, media: entity,
extra: [ extra: [
{ {
name: 'Seasons', name: 'Requested Seasons',
value: request.seasons value: request.seasons
.map((season) => season.seasonNumber) .map((season) => season.seasonNumber)
.join(', '), .join(', '),
}, },
], ],
request: request, request,
}); });
} }
} }

@ -0,0 +1,53 @@
doctype html
head
meta(charset='utf-8')
meta(name='x-apple-disable-message-reformatting')
meta(http-equiv='x-ua-compatible' content='ie=edge')
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no, date=no, address=no, email=no')
link(href='https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' rel='stylesheet' media='screen')
style.
.title:hover * {
text-decoration: underline;
}
@media only screen and (max-width:600px) {
table {
font-size: 20px !important;
width: 100% !important;
}
}
div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
table(style='margin: 0 auto; font-family: Inter, Arial, sans-serif; color: #fff; font-size: 16px; width: 26rem;')
tr
td(style="text-align: center;")
if applicationUrl
a(href=applicationUrl style='margin: 0 1rem;')
img(src=applicationUrl +'/logo_full.png' style='width: 26rem; image-rendering: crisp-edges; image-rendering: -webkit-optimize-contrast;')
else
div(style='margin: 0 1rem 2.5rem; font-size: 3em; font-weight: 700;')
| #{applicationTitle}
if recipientName !== recipientEmail
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| Hi, #{recipientName.replace(/\.|@/g, ((x) => x + '\ufeff'))}!
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{body}
if issueComment
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{issueComment}
else if issueDescription
tr
td(style='text-align: center;')
div(style='margin: 1rem 0 0; font-size: 1.25em;')
| #{issueDescription}
if actionUrl
tr
td
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| View Issue in #{applicationTitle}

@ -0,0 +1 @@
!= `${event} - ${mediaName} [${applicationTitle}]`

@ -66,4 +66,4 @@ div(style='display: block; background-color: #111827; padding: 2.5rem 0;')
td td
a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;') a(href=actionUrl style='display: block; margin: 1.5rem 3rem 0; text-decoration: none; font-size: 1.0em; line-height: 2.25em;')
span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);') span(style='padding: 0.2rem; font-weight: 500; text-align: center; border-radius: 10px; background-color: rgb(99,102,241); color: #fff; display: block; border: 1px solid rgba(255,255,255,0.2);')
| Open in #{applicationTitle} | View Media in #{applicationTitle}

@ -1 +1 @@
!= `${requestType} - ${mediaName} [${applicationTitle}]` != `${event} - ${mediaName} [${applicationTitle}]`

@ -4,7 +4,7 @@ import { IssueType } from '../../../server/constants/issue';
const messages = defineMessages({ const messages = defineMessages({
issueAudio: 'Audio', issueAudio: 'Audio',
issueVideo: 'Video', issueVideo: 'Video',
issueSubtitles: 'Subtitles', issueSubtitles: 'Subtitle',
issueOther: 'Other', issueOther: 'Other',
}); });

@ -44,12 +44,21 @@ const messages = defineMessages({
issuecommentDescription: issuecommentDescription:
'Send notifications when issues receive new comments.', 'Send notifications when issues receive new comments.',
userissuecommentDescription: userissuecommentDescription:
'Get notified when your issues receive new comments.', 'Get notified when issues you reported receive new comments.',
adminissuecommentDescription: adminissuecommentDescription:
'Get notified when issues receive new comments.', 'Get notified when other users comment on issues.',
issueresolved: 'Issue Resolved', issueresolved: 'Issue Resolved',
issueresolvedDescription: 'Send notifications when issues are resolved.', issueresolvedDescription: 'Send notifications when issues are resolved.',
userissueresolvedDescription: 'Get notified when your issues are resolved.', userissueresolvedDescription:
'Get notified when issues you reported are resolved.',
adminissueresolvedDescription:
'Get notified when issues are resolved by other users.',
issuereopened: 'Issue Reopened',
issuereopenedDescription: 'Send notifications when issues are reopened.',
userissuereopenedDescription:
'Get notified when issues you reported are reopened.',
adminissuereopenedDescription:
'Get notified when issues are reopened by other users.',
}); });
export const hasNotificationType = ( export const hasNotificationType = (
@ -90,6 +99,7 @@ export enum Notification {
ISSUE_CREATED = 256, ISSUE_CREATED = 256,
ISSUE_COMMENT = 512, ISSUE_COMMENT = 512,
ISSUE_RESOLVED = 1024, ISSUE_RESOLVED = 1024,
ISSUE_REOPENED = 2048,
} }
export const ALL_NOTIFICATIONS = Object.values(Notification) export const ALL_NOTIFICATIONS = Object.values(Notification)
@ -287,7 +297,9 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
name: intl.formatMessage(messages.issueresolved), name: intl.formatMessage(messages.issueresolved),
description: intl.formatMessage( description: intl.formatMessage(
user user
? messages.userissueresolvedDescription ? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissueresolvedDescription
: messages.userissueresolvedDescription
: messages.issueresolvedDescription : messages.issueresolvedDescription
), ),
value: Notification.ISSUE_RESOLVED, value: Notification.ISSUE_RESOLVED,
@ -296,7 +308,27 @@ const NotificationTypeSelector: React.FC<NotificationTypeSelectorProps> = ({
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], { !hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or', type: 'or',
}), }),
hasNotifyUser: true, hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
},
{
id: 'issue-reopened',
name: intl.formatMessage(messages.issuereopened),
description: intl.formatMessage(
user
? hasPermission(Permission.MANAGE_ISSUES)
? messages.adminissuereopenedDescription
: messages.userissuereopenedDescription
: messages.issuereopenedDescription
),
value: Notification.ISSUE_REOPENED,
hidden:
user &&
!hasPermission([Permission.MANAGE_ISSUES, Permission.CREATE_ISSUES], {
type: 'or',
}),
hasNotifyUser:
!user || hasPermission(Permission.MANAGE_ISSUES) ? false : true,
}, },
]; ];

@ -6,6 +6,7 @@ import { defineMessages, useIntl } from 'react-intl';
import { useToasts } from 'react-toast-notifications'; import { useToasts } from 'react-toast-notifications';
import useSWR from 'swr'; import useSWR from 'swr';
import * as Yup from 'yup'; import * as Yup from 'yup';
import useSettings from '../../../hooks/useSettings';
import globalMessages from '../../../i18n/globalMessages'; import globalMessages from '../../../i18n/globalMessages';
import Button from '../../Common/Button'; import Button from '../../Common/Button';
import LoadingSpinner from '../../Common/LoadingSpinner'; import LoadingSpinner from '../../Common/LoadingSpinner';
@ -29,6 +30,7 @@ const messages = defineMessages({
const NotificationsDiscord: React.FC = () => { const NotificationsDiscord: React.FC = () => {
const intl = useIntl(); const intl = useIntl();
const settings = useSettings();
const { addToast, removeToast } = useToasts(); const { addToast, removeToast } = useToasts();
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
const { data, error, revalidate } = useSWR( const { data, error, revalidate } = useSWR(
@ -195,7 +197,12 @@ const NotificationsDiscord: React.FC = () => {
</label> </label>
<div className="form-input"> <div className="form-input">
<div className="form-input-field"> <div className="form-input-field">
<Field id="botUsername" name="botUsername" type="text" /> <Field
id="botUsername"
name="botUsername"
type="text"
placeholder={settings.currentSettings.applicationTitle}
/>
</div> </div>
{errors.botUsername && touched.botUsername && ( {errors.botUsername && touched.botUsername && (
<div className="error">{errors.botUsername}</div> <div className="error">{errors.botUsername}</div>

@ -18,27 +18,38 @@ const JSONEditor = dynamic(() => import('../../../JSONEditor'), { ssr: false });
const defaultPayload = { const defaultPayload = {
notification_type: '{{notification_type}}', notification_type: '{{notification_type}}',
event: '{{event}}',
subject: '{{subject}}', subject: '{{subject}}',
message: '{{message}}', message: '{{message}}',
image: '{{image}}', image: '{{image}}',
email: '{{notifyuser_email}}',
username: '{{notifyuser_username}}',
avatar: '{{notifyuser_avatar}}',
'{{media}}': { '{{media}}': {
media_type: '{{media_type}}', media_type: '{{media_type}}',
tmdbId: '{{media_tmdbid}}', tmdbId: '{{media_tmdbid}}',
imdbId: '{{media_imdbid}}',
tvdbId: '{{media_tvdbid}}', tvdbId: '{{media_tvdbid}}',
status: '{{media_status}}', status: '{{media_status}}',
status4k: '{{media_status4k}}', status4k: '{{media_status4k}}',
}, },
'{{extra}}': [],
'{{request}}': { '{{request}}': {
request_id: '{{request_id}}', request_id: '{{request_id}}',
requestedBy_email: '{{requestedBy_email}}', requestedBy_email: '{{requestedBy_email}}',
requestedBy_username: '{{requestedBy_username}}', requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}', requestedBy_avatar: '{{requestedBy_avatar}}',
}, },
'{{issue}}': {
issue_id: '{{issue_id}}',
issue_type: '{{issue_type}}',
issue_status: '{{issue_status}}',
reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}',
},
'{{comment}}': {
comment_message: '{{comment_message}}',
commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}',
},
'{{extra}}': [],
}; };
const messages = defineMessages({ const messages = defineMessages({

@ -102,7 +102,7 @@
"components.IssueModal.CreateIssueModal.whatswrong": "What's wrong?", "components.IssueModal.CreateIssueModal.whatswrong": "What's wrong?",
"components.IssueModal.issueAudio": "Audio", "components.IssueModal.issueAudio": "Audio",
"components.IssueModal.issueOther": "Other", "components.IssueModal.issueOther": "Other",
"components.IssueModal.issueSubtitles": "Subtitles", "components.IssueModal.issueSubtitles": "Subtitle",
"components.IssueModal.issueVideo": "Video", "components.IssueModal.issueVideo": "Video",
"components.LanguageSelector.languageServerDefault": "Default ({language})", "components.LanguageSelector.languageServerDefault": "Default ({language})",
"components.LanguageSelector.originalLanguageDefault": "All Languages", "components.LanguageSelector.originalLanguageDefault": "All Languages",
@ -169,11 +169,15 @@
"components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}", "components.MovieDetails.studio": "{studioCount, plural, one {Studio} other {Studios}}",
"components.MovieDetails.viewfullcrew": "View Full Crew", "components.MovieDetails.viewfullcrew": "View Full Crew",
"components.MovieDetails.watchtrailer": "Watch Trailer", "components.MovieDetails.watchtrailer": "Watch Trailer",
"components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when issues receive new comments.", "components.NotificationTypeSelector.adminissuecommentDescription": "Get notified when other users comment on issues.",
"components.NotificationTypeSelector.adminissuereopenedDescription": "Get notified when issues are reopened by other users.",
"components.NotificationTypeSelector.adminissueresolvedDescription": "Get notified when issues are resolved by other users.",
"components.NotificationTypeSelector.issuecomment": "Issue Comment", "components.NotificationTypeSelector.issuecomment": "Issue Comment",
"components.NotificationTypeSelector.issuecommentDescription": "Send notifications when issues receive new comments.", "components.NotificationTypeSelector.issuecommentDescription": "Send notifications when issues receive new comments.",
"components.NotificationTypeSelector.issuecreated": "Issue Reported", "components.NotificationTypeSelector.issuecreated": "Issue Reported",
"components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when issues are reported.", "components.NotificationTypeSelector.issuecreatedDescription": "Send notifications when issues are reported.",
"components.NotificationTypeSelector.issuereopened": "Issue Reopened",
"components.NotificationTypeSelector.issuereopenedDescription": "Send notifications when issues are reopened.",
"components.NotificationTypeSelector.issueresolved": "Issue Resolved", "components.NotificationTypeSelector.issueresolved": "Issue Resolved",
"components.NotificationTypeSelector.issueresolvedDescription": "Send notifications when issues are resolved.", "components.NotificationTypeSelector.issueresolvedDescription": "Send notifications when issues are resolved.",
"components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved", "components.NotificationTypeSelector.mediaAutoApproved": "Media Automatically Approved",
@ -189,9 +193,10 @@
"components.NotificationTypeSelector.mediarequested": "Media Requested", "components.NotificationTypeSelector.mediarequested": "Media Requested",
"components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.", "components.NotificationTypeSelector.mediarequestedDescription": "Send notifications when users submit new media requests which require approval.",
"components.NotificationTypeSelector.notificationTypes": "Notification Types", "components.NotificationTypeSelector.notificationTypes": "Notification Types",
"components.NotificationTypeSelector.userissuecommentDescription": "Get notified when your issues receive new comments.", "components.NotificationTypeSelector.userissuecommentDescription": "Get notified when issues you reported receive new comments.",
"components.NotificationTypeSelector.userissuecreatedDescription": "Get notified when other users report issues.", "components.NotificationTypeSelector.userissuecreatedDescription": "Get notified when other users report issues.",
"components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when your issues are resolved.", "components.NotificationTypeSelector.userissuereopenedDescription": "Get notified when issues you reported are reopened.",
"components.NotificationTypeSelector.userissueresolvedDescription": "Get notified when issues you reported are resolved.",
"components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.", "components.NotificationTypeSelector.usermediaAutoApprovedDescription": "Get notified when other users submit new media requests which are automatically approved.",
"components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.", "components.NotificationTypeSelector.usermediaapprovedDescription": "Get notified when your media requests are approved.",
"components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.", "components.NotificationTypeSelector.usermediaavailableDescription": "Get notified when your media requests become available.",

Loading…
Cancel
Save