feat(notifications): support multiple Discord IDs (#2712)

This commit is contained in:
adis veletanlic
2026-06-01 15:00:06 +02:00
committed by GitHub
parent 5fc7a40ad8
commit 63175f52ce
20 changed files with 279 additions and 157 deletions

View File

@@ -53,13 +53,13 @@ Customize the JSON payload to suit your needs. Seerr provides several [template
These variables are for the target recipient of the notification.
| Variable | Value |
| ---------------------------------------- | ------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{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) |
| Variable | Value |
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
| `{{notifyuser_username}}` | The target notification recipient's username |
| `{{notifyuser_email}}` | The target notification recipient's email address |
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
| `{{notifyuser_settings_discordIds}}` | The target notification recipient's Discord ID(s) as a JSON array (if set) |
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
:::info
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
@@ -113,15 +113,15 @@ The `{{request}}` will be `null` if there is no relevant media object for the no
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_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
| 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_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
| `{{requestedBy_settings_discordIds}}` | The requesting user's Discord ID(s) as a JSON array (if set) |
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Issue
@@ -129,14 +129,14 @@ The `{{issue}}` will be `null` if there is no relevant media object for the noti
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) |
| 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_discordIds}}` | The reporting user's Discord ID(s) as a JSON array (if set) |
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
#### Comment
@@ -144,11 +144,11 @@ The `{{comment}}` will be `null` if there is no relevant media object for the no
The following special variables are only included in issue comment-related notifications.
| Variable | Value |
| ----------------------------------------- | ----------------------------------------------- |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{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) |
| Variable | Value |
| ----------------------------------------- | ------------------------------------------------------------------------------ |
| `{{comment_message}}` | The comment message |
| `{{commentedBy_username}}` | The commenting user's username |
| `{{commentedBy_email}}` | The commenting user's email address |
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
| `{{commentedBy_settings_discordIds}}` | The commenting user's Discord ID(s) as a JSON array (if set) |
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

View File

@@ -148,10 +148,6 @@ components:
email:
type: string
example: 'user@example.com'
discordId:
type: string
nullable: true
example: '123456789'
locale:
type: string
nullable: true
@@ -1956,8 +1952,10 @@ components:
discordEnabledTypes:
type: number
nullable: true
discordId:
type: string
discordIds:
type: array
items:
type: string
nullable: true
pushbulletAccessToken:
type: string

View File

@@ -0,0 +1,27 @@
export const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
export enum EmbedColors {
DEFAULT = 0,
AQUA = 1752220,
GREEN = 3066993,
BLUE = 3447003,
PURPLE = 10181046,
GOLD = 15844367,
ORANGE = 15105570,
RED = 15158332,
GREY = 9807270,
DARKER_GREY = 8359053,
NAVY = 3426654,
DARK_AQUA = 1146986,
DARK_GREEN = 2067276,
DARK_BLUE = 2123412,
DARK_PURPLE = 7419530,
DARK_GOLD = 12745742,
DARK_ORANGE = 11027200,
DARK_RED = 10038562,
DARK_GREY = 9936031,
LIGHT_GREY = 12370112,
DARK_NAVY = 2899536,
LUMINOUS_VIVID_PINK = 16580705,
DARK_VIVID_PINK = 12320855,
}

View File

@@ -14,6 +14,19 @@ export const ALL_NOTIFICATIONS = Object.values(Notification)
.filter((v) => !isNaN(Number(v)))
.reduce((a, v) => a + Number(v), 0);
// convert between DB representation (JSON string) into typescript array
const jsonArrayTransformer = {
from: (v: string | null): string[] => {
try {
return v ? JSON.parse(v) : [];
} catch {
return [];
}
},
to: (v: string[] | null): string | null =>
v?.length ? JSON.stringify(v) : null,
};
@Entity()
export class UserSettings {
constructor(init?: Partial<UserSettings>) {
@@ -42,8 +55,8 @@ export class UserSettings {
@Column({ nullable: true })
public pgpKey?: string;
@Column({ nullable: true })
public discordId?: string;
@Column({ type: 'text', nullable: true, transformer: jsonArrayTransformer })
public discordIds: string[];
@Column({ nullable: true })
public pushbulletAccessToken?: string;

View File

@@ -3,7 +3,6 @@ import type { NotificationAgentKey } from '@server/lib/settings';
export interface UserSettingsGeneralResponse {
username?: string;
email?: string;
discordId?: string;
locale?: string;
discoverRegion?: string;
streamingRegion?: string;
@@ -26,7 +25,7 @@ export interface UserSettingsNotificationsResponse {
pgpKey?: string;
discordEnabled?: boolean;
discordEnabledTypes?: number;
discordId?: string;
discordIds?: string[];
pushbulletAccessToken?: string;
pushoverApplicationToken?: string;
pushoverUserKey?: string;

View File

@@ -1,3 +1,7 @@
import {
DISCORD_SNOWFLAKE_REGEX,
EmbedColors,
} from '@server/constants/discord';
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
import { getRepository } from '@server/datasource';
import { User } from '@server/entity/User';
@@ -16,31 +20,8 @@ import {
import type { NotificationAgent, NotificationPayload } from './agent';
import { BaseAgent } from './agent';
enum EmbedColors {
DEFAULT = 0,
AQUA = 1752220,
GREEN = 3066993,
BLUE = 3447003,
PURPLE = 10181046,
GOLD = 15844367,
ORANGE = 15105570,
RED = 15158332,
GREY = 9807270,
DARKER_GREY = 8359053,
NAVY = 3426654,
DARK_AQUA = 1146986,
DARK_GREEN = 2067276,
DARK_BLUE = 2123412,
DARK_PURPLE = 7419530,
DARK_GOLD = 12745742,
DARK_ORANGE = 11027200,
DARK_RED = 10038562,
DARK_GREY = 9936031,
LIGHT_GREY = 12370112,
DARK_NAVY = 2899536,
LUMINOUS_VIVID_PINK = 16580705,
DARK_VIVID_PINK = 12320855,
}
const isValidSnowflake = (id: string): boolean =>
DISCORD_SNOWFLAKE_REGEX.test(id);
interface DiscordImageEmbed {
url?: string;
@@ -278,9 +259,12 @@ class DiscordAgent
NotificationAgentKey.DISCORD,
type
) &&
payload.notifyUser.settings.discordId
payload.notifyUser.settings.discordIds?.length
) {
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
const validIds = payload.notifyUser.settings.discordIds.filter(
(id) => isValidSnowflake(id)
);
userMentions.push(...validIds.map((id) => `<@${id}>`));
}
}
@@ -296,16 +280,30 @@ class DiscordAgent
NotificationAgentKey.DISCORD,
type
) &&
user.settings.discordId &&
user.settings.discordIds?.length &&
shouldSendAdminNotification(type, user, payload)
)
.map((user) => `<@${user.settings?.discordId}>`)
.flatMap((user) =>
user
.settings!.discordIds.filter((id) => isValidSnowflake(id))
.map((id) => `<@${id}>`)
)
);
}
}
if (settings.options.webhookRoleId) {
const allowedUserIds = userMentions.map((mention) =>
mention.replace(/[<@>]/g, '')
);
const allowedRoleIds: string[] = [];
if (
settings.options.webhookRoleId &&
isValidSnowflake(settings.options.webhookRoleId)
) {
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
allowedRoleIds.push(settings.options.webhookRoleId);
}
// Discord webhooks go to a channel, not per-user,
@@ -322,6 +320,10 @@ class DiscordAgent
avatar_url: settings.options.botAvatarUrl,
embeds: [this.buildEmbed(type, payload, locale)],
content: userMentions.join(' '),
allowed_mentions: {
users: allowedUserIds,
roles: allowedRoleIds,
},
} as DiscordWebhookPayload);
return true;

View File

@@ -23,7 +23,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
notifyuser_username: 'notifyUser.displayName',
notifyuser_email: 'notifyUser.email',
notifyuser_avatar: 'notifyUser.avatar',
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
notifyuser_settings_discordIds: 'notifyUser.settings.discordIds',
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
media_imdbid: 'media.imdbId',
media_tmdbid: 'media.tmdbId',
@@ -42,7 +42,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
requestedBy_username: 'request.requestedBy.displayName',
requestedBy_email: 'request.requestedBy.email',
requestedBy_avatar: 'request.requestedBy.avatar',
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
requestedBy_settings_discordIds: 'request.requestedBy.settings.discordIds',
requestedBy_settings_telegramChatId:
'request.requestedBy.settings.telegramChatId',
issue_id: 'issue.id',
@@ -53,13 +53,13 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
reportedBy_username: 'issue.createdBy.displayName',
reportedBy_email: 'issue.createdBy.email',
reportedBy_avatar: 'issue.createdBy.avatar',
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
reportedBy_settings_discordIds: 'issue.createdBy.settings.discordIds',
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_discordIds: 'comment.user.settings.discordIds',
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
};

View File

@@ -0,0 +1,30 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiscordIdsColumn1779783365432 implements MigrationInterface {
name = 'AddDiscordIdsColumn1779783365432';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "discordIds" text`
);
// same for postgres (convert existing single ID into list with one entry)
await queryRunner.query(
`UPDATE "user_settings" SET "discordIds" = '["' || "discordId" || '"]' WHERE "discordId" IS NOT NULL AND "discordId" != ''`
);
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "discordId"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" ADD "discordId" character varying`
);
await queryRunner.query(
`UPDATE "user_settings" SET "discordId" = ("discordIds"::jsonb ->> 0) WHERE "discordIds" IS NOT NULL AND "discordIds" != ''`
);
await queryRunner.query(
`ALTER TABLE "user_settings" DROP COLUMN "discordIds"`
);
}
}

View File

@@ -0,0 +1,31 @@
import type { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDiscordIdsColumn1779783365432 implements MigrationInterface {
name = 'AddDiscordIdsColumn1779783365432';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "temporary_user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordIds" text, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "temporary_user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordIds", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", CASE WHEN "discordId" IS NOT NULL AND "discordId" != '' THEN '["' || "discordId" || '"]' ELSE NULL END, "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "user_settings"`
);
await queryRunner.query(`DROP TABLE "user_settings"`);
await queryRunner.query(
`ALTER TABLE "temporary_user_settings" RENAME TO "user_settings"`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "user_settings" RENAME TO "temporary_user_settings"`
);
await queryRunner.query(
`CREATE TABLE "user_settings" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "locale" varchar NOT NULL DEFAULT (''), "discoverRegion" varchar, "streamingRegion" varchar, "originalLanguage" varchar, "pgpKey" varchar, "discordId" varchar, "pushbulletAccessToken" varchar, "pushoverApplicationToken" varchar, "pushoverUserKey" varchar, "pushoverSound" varchar, "telegramChatId" varchar, "telegramSendSilently" boolean, "watchlistSyncMovies" boolean, "watchlistSyncTv" boolean, "notificationTypes" text, "userId" integer, "telegramMessageThreadId" varchar, CONSTRAINT "REL_986a2b6d3c05eb4091bb8066f7" UNIQUE ("userId"), CONSTRAINT "FK_986a2b6d3c05eb4091bb8066f78" FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`
);
await queryRunner.query(
`INSERT INTO "user_settings"("id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", "discordId", "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId") SELECT "id", "locale", "discoverRegion", "streamingRegion", "originalLanguage", "pgpKey", json_extract("discordIds", '$[0]'), "pushbulletAccessToken", "pushoverApplicationToken", "pushoverUserKey", "pushoverSound", "telegramChatId", "telegramSendSilently", "watchlistSyncMovies", "watchlistSyncTv", "notificationTypes", "userId", "telegramMessageThreadId" FROM "temporary_user_settings"`
);
await queryRunner.query(`DROP TABLE "temporary_user_settings"`);
}
}

View File

@@ -48,7 +48,6 @@ userSettingsRoutes.get<{ id: string }, UserSettingsGeneralResponse>(
return res.status(200).json({
username: user.username,
email: user.email,
discordId: user.settings?.discordId,
locale: user.settings?.locale,
discoverRegion: user.settings?.discoverRegion,
streamingRegion: user.settings?.streamingRegion,
@@ -122,7 +121,6 @@ userSettingsRoutes.post<
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
discordId: req.body.discordId,
locale: req.body.locale,
discoverRegion: req.body.discoverRegion,
streamingRegion: req.body.streamingRegion,
@@ -131,7 +129,6 @@ userSettingsRoutes.post<
watchlistSyncTv: req.body.watchlistSyncTv,
});
} else {
user.settings.discordId = req.body.discordId;
user.settings.locale = req.body.locale;
user.settings.discoverRegion = req.body.discoverRegion;
user.settings.streamingRegion = req.body.streamingRegion;
@@ -144,7 +141,6 @@ userSettingsRoutes.post<
return res.status(200).json({
username: savedUser.username,
discordId: savedUser.settings?.discordId,
locale: savedUser.settings?.locale,
discoverRegion: savedUser.settings?.discoverRegion,
streamingRegion: savedUser.settings?.streamingRegion,
@@ -543,7 +539,7 @@ userSettingsRoutes.get<{ id: string }, UserSettingsNotificationsResponse>(
settings?.discord.enabled && settings.discord.options.enableMentions
? settings.discord.types
: 0,
discordId: user.settings?.discordId,
discordIds: user.settings?.discordIds ?? [],
pushbulletAccessToken: user.settings?.pushbulletAccessToken,
pushoverApplicationToken: user.settings?.pushoverApplicationToken,
pushoverUserKey: user.settings?.pushoverUserKey,
@@ -585,11 +581,14 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
});
}
const discordIds =
req.body.discordIds?.filter((id: string) => id !== '') ?? [];
if (!user.settings) {
user.settings = new UserSettings({
user: req.user,
pgpKey: req.body.pgpKey,
discordId: req.body.discordId,
discordIds,
pushbulletAccessToken: req.body.pushbulletAccessToken,
pushoverApplicationToken: req.body.pushoverApplicationToken,
pushoverUserKey: req.body.pushoverUserKey,
@@ -600,7 +599,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
});
} else {
user.settings.pgpKey = req.body.pgpKey;
user.settings.discordId = req.body.discordId;
user.settings.discordIds = discordIds;
user.settings.pushbulletAccessToken = req.body.pushbulletAccessToken;
user.settings.pushoverApplicationToken =
req.body.pushoverApplicationToken;
@@ -621,7 +620,7 @@ userSettingsRoutes.post<{ id: string }, UserSettingsNotificationsResponse>(
return res.status(200).json({
pgpKey: user.settings.pgpKey,
discordId: user.settings.discordId,
discordIds: user.settings.discordIds ?? [],
pushbulletAccessToken: user.settings.pushbulletAccessToken,
pushoverApplicationToken: user.settings.pushoverApplicationToken,
pushoverUserKey: user.settings.pushoverUserKey,

View File

@@ -50,7 +50,7 @@ const defaultPayload = {
requestedBy_username: '{{requestedBy_username}}',
requestedBy_avatar: '{{requestedBy_avatar}}',
requestedBy_jellyfinUserId: '{{requestedBy_jellyfinUserId}}',
requestedBy_settings_discordId: '{{requestedBy_settings_discordId}}',
requestedBy_settings_discordIds: '{{requestedBy_settings_discordIds}}',
requestedBy_settings_telegramChatId:
'{{requestedBy_settings_telegramChatId}}',
},
@@ -61,7 +61,7 @@ const defaultPayload = {
reportedBy_email: '{{reportedBy_email}}',
reportedBy_username: '{{reportedBy_username}}',
reportedBy_avatar: '{{reportedBy_avatar}}',
reportedBy_settings_discordId: '{{reportedBy_settings_discordId}}',
reportedBy_settings_discordIds: '{{reportedBy_settings_discordIds}}',
reportedBy_settings_telegramChatId:
'{{reportedBy_settings_telegramChatId}}',
},
@@ -70,7 +70,7 @@ const defaultPayload = {
commentedBy_email: '{{commentedBy_email}}',
commentedBy_username: '{{commentedBy_username}}',
commentedBy_avatar: '{{commentedBy_avatar}}',
commentedBy_settings_discordId: '{{commentedBy_settings_discordId}}',
commentedBy_settings_discordIds: '{{commentedBy_settings_discordIds}}',
commentedBy_settings_telegramChatId:
'{{commentedBy_settings_telegramChatId}}',
},

View File

@@ -61,12 +61,8 @@ const messages = defineMessages(
enableOverride: 'Override Global Limit',
applanguage: 'Display Language',
languageDefault: 'Default ({language})',
discordId: 'Discord User ID',
discordIdTip:
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account',
validationemailrequired: 'Email required',
validationemailformat: 'Valid email required',
validationDiscordId: 'You must provide a valid Discord user ID',
plexwatchlistsyncmovies: 'Auto-Request Movies',
plexwatchlistsyncmoviestip:
'Automatically request movies on your <PlexWatchlistSupportLink>Plex Watchlist</PlexWatchlistSupportLink>',
@@ -119,9 +115,6 @@ const UserGeneralSettings = () => {
(value) =>
!value || validator.isEmail(value, { require_tld: false })
),
discordId: Yup.string()
.nullable()
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
});
useEffect(() => {
@@ -158,7 +151,6 @@ const UserGeneralSettings = () => {
initialValues={{
displayName: data?.username !== user?.email ? data?.username : '',
email: data?.email?.includes('@') ? data.email : '',
discordId: data?.discordId ?? '',
locale: data?.locale,
discoverRegion: data?.discoverRegion,
streamingRegion: data?.streamingRegion,
@@ -178,7 +170,6 @@ const UserGeneralSettings = () => {
username: values.displayName,
email:
values.email || user?.jellyfinUsername || user?.plexUsername,
discordId: values.discordId,
locale: values.locale,
discoverRegion: values.discoverRegion,
streamingRegion: values.streamingRegion,
@@ -341,36 +332,6 @@ const UserGeneralSettings = () => {
)}
</div>
</div>
<div className="form-row">
<label htmlFor="discordId" className="text-label">
{intl.formatMessage(messages.discordId)}
{currentUser?.id === user?.id && (
<span className="label-tip">
{intl.formatMessage(messages.discordIdTip, {
FindDiscordIdLink: (msg: React.ReactNode) => (
<a
href="https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID-"
target="_blank"
rel="noreferrer"
>
{msg}
</a>
),
})}
</span>
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="discordId" name="discordId" type="text" />
</div>
{errors.discordId &&
touched.discordId &&
typeof errors.discordId === 'string' && (
<div className="error">{errors.discordId}</div>
)}
</div>
</div>
<div className="form-row">
<label htmlFor="locale" className="text-label">
{intl.formatMessage(messages.applanguage)}

View File

@@ -5,7 +5,12 @@ import useToasts from '@app/hooks/useToasts';
import { useUser } from '@app/hooks/useUser';
import globalMessages from '@app/i18n/globalMessages';
import defineMessages from '@app/utils/defineMessages';
import { ArrowDownOnSquareIcon } from '@heroicons/react/24/outline';
import {
ArrowDownOnSquareIcon,
PlusIcon,
TrashIcon,
} from '@heroicons/react/24/outline';
import { DISCORD_SNOWFLAKE_REGEX } from '@server/constants/discord';
import type { UserSettingsNotificationsResponse } from '@server/interfaces/api/userSettingsInterfaces';
import axios from 'axios';
import { Field, Form, Formik } from 'formik';
@@ -19,10 +24,13 @@ const messages = defineMessages(
{
discordsettingssaved: 'Discord notification settings saved successfully!',
discordsettingsfailed: 'Discord notification settings failed to save.',
discordId: 'User ID',
discordId: 'User IDs',
discordIdTip:
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account',
validationDiscordId: 'You must provide a valid user ID',
'The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account. For multiple household accounts you can add more than one Discord user ID.',
discordIdPlaceholder: 'Discord User ID',
discordIdAdd: 'Add User ID',
discordIdRemove: 'Remove',
validationDiscordId: 'Each ID must be a valid Discord user ID',
}
);
@@ -41,16 +49,20 @@ const UserNotificationsDiscord = () => {
);
const UserNotificationsDiscordSchema = Yup.object().shape({
discordId: Yup.string()
discordIds: Yup.array()
.of(
Yup.string().matches(DISCORD_SNOWFLAKE_REGEX, {
message: intl.formatMessage(messages.validationDiscordId),
excludeEmptyString: true,
})
)
.when('types', {
is: (types: number) => !!types,
then: (schema) =>
schema
.nullable()
.required(intl.formatMessage(messages.validationDiscordId)),
otherwise: (schema) => schema.nullable(),
})
.matches(/^\d{17,19}$/, intl.formatMessage(messages.validationDiscordId)),
.compact((value) => value === '')
.min(1, intl.formatMessage(messages.validationDiscordId)),
}),
});
if (!data && !error) {
@@ -60,7 +72,7 @@ const UserNotificationsDiscord = () => {
return (
<Formik
initialValues={{
discordId: data?.discordId,
discordIds: data?.discordIds ?? [''],
types:
(data?.discordEnabledTypes ?? 0) &
(data?.notificationTypes.discord ?? 0),
@@ -71,7 +83,7 @@ const UserNotificationsDiscord = () => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
pgpKey: data?.pgpKey,
discordId: values.discordId,
discordIds: values.discordIds,
pushbulletAccessToken: data?.pushbulletAccessToken,
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,
@@ -107,7 +119,7 @@ const UserNotificationsDiscord = () => {
return (
<Form className="section">
<div className="form-row">
<label htmlFor="discordId" className="text-label">
<label className="text-label">
{intl.formatMessage(messages.discordId)}
{!!data?.discordEnabledTypes && (
<span className="label-required">*</span>
@@ -129,13 +141,64 @@ const UserNotificationsDiscord = () => {
)}
</label>
<div className="form-input-area">
<div className="form-input-field">
<Field id="discordId" name="discordId" type="text" />
<div className="space-y-2">
{values.discordIds.map((_id: string, index: number) => (
<div key={index} className="flex gap-2">
<div className="flex-1">
<div className="form-input-field">
<Field
name={`discordIds.${index}`}
type="text"
placeholder={intl.formatMessage(
messages.discordIdPlaceholder
)}
/>
</div>
{Array.isArray(errors.discordIds) &&
errors.discordIds[index] &&
Array.isArray(touched.discordIds) &&
touched.discordIds[index] && (
<div className="error">
{errors.discordIds[index]}
</div>
)}
</div>
{values.discordIds.length > 1 && (
<div className="flex items-center">
<Button
buttonType="danger"
buttonSize="sm"
onClick={(event) => {
event.preventDefault();
const newIds = values.discordIds.filter(
(_: string, idx: number) => idx !== index
);
setFieldValue('discordIds', newIds);
}}
title={intl.formatMessage(messages.discordIdRemove)}
>
<TrashIcon />
</Button>
</div>
)}
</div>
))}
<Button
buttonType="default"
buttonSize="sm"
onClick={(event) => {
event.preventDefault();
setFieldValue('discordIds', [...values.discordIds, '']);
}}
>
<PlusIcon />
<span>{intl.formatMessage(messages.discordIdAdd)}</span>
</Button>
</div>
{errors.discordId &&
touched.discordId &&
typeof errors.discordId === 'string' && (
<div className="error">{errors.discordId}</div>
{errors.discordIds &&
touched.discordIds &&
typeof errors.discordIds === 'string' && (
<div className="error">{errors.discordIds}</div>
)}
</div>
</div>

View File

@@ -69,7 +69,7 @@ const UserEmailSettings = () => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
pgpKey: values.pgpKey,
discordId: data?.discordId,
discordIds: data?.discordIds,
pushbulletAccessToken: data?.pushbulletAccessToken,
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,

View File

@@ -70,7 +70,7 @@ const UserPushbulletSettings = () => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
pgpKey: data?.pgpKey,
discordId: data?.discordId,
discordIds: data?.discordIds,
pushbulletAccessToken: values.pushbulletAccessToken,
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,

View File

@@ -101,7 +101,7 @@ const UserPushoverSettings = () => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
pgpKey: data?.pgpKey,
discordId: data?.discordId,
discordIds: data?.discordIds,
pushbulletAccessToken: data?.pushbulletAccessToken,
pushoverApplicationToken: values.pushoverApplicationToken,
pushoverUserKey: values.pushoverUserKey,

View File

@@ -87,7 +87,7 @@ const UserTelegramSettings = () => {
try {
await axios.post(`/api/v1/user/${user?.id}/settings/notifications`, {
pgpKey: data?.pgpKey,
discordId: data?.discordId,
discordIds: data?.discordIds,
pushbulletAccessToken: data?.pushbulletAccessToken,
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,

View File

@@ -256,7 +256,7 @@ const UserWebPushSettings = () => {
`/api/v1/user/${user?.id}/settings/notifications`,
{
pgpKey: data?.pgpKey,
discordId: data?.discordId,
discordIds: data?.discordIds,
pushbulletAccessToken: data?.pushbulletAccessToken,
pushoverApplicationToken: data?.pushoverApplicationToken,
pushoverUserKey: data?.pushoverUserKey,

View File

@@ -29,7 +29,6 @@ export interface User {
type NotificationAgentTypes = Record<NotificationAgentKey, number>;
export interface UserSettings {
discordId?: string;
discoverRegion?: string;
streamingRegion?: string;
originalLanguage?: string;

View File

@@ -1452,8 +1452,6 @@
"components.UserProfile.UserSettings.UserGeneralSettings.accounttype": "Account Type",
"components.UserProfile.UserSettings.UserGeneralSettings.admin": "Admin",
"components.UserProfile.UserSettings.UserGeneralSettings.applanguage": "Display Language",
"components.UserProfile.UserSettings.UserGeneralSettings.discordId": "Discord User ID",
"components.UserProfile.UserSettings.UserGeneralSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your Discord user account",
"components.UserProfile.UserSettings.UserGeneralSettings.discoverRegion": "Discover Region",
"components.UserProfile.UserSettings.UserGeneralSettings.discoverRegionTip": "Filter content by regional availability",
"components.UserProfile.UserSettings.UserGeneralSettings.displayName": "Display Name",
@@ -1486,7 +1484,6 @@
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsFailureEmailEmpty": "Another user already has this username. You must set an email",
"components.UserProfile.UserSettings.UserGeneralSettings.toastSettingsSuccess": "Settings saved successfully!",
"components.UserProfile.UserSettings.UserGeneralSettings.user": "User",
"components.UserProfile.UserSettings.UserGeneralSettings.validationDiscordId": "You must provide a valid Discord user ID",
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailformat": "Valid email required",
"components.UserProfile.UserSettings.UserGeneralSettings.validationemailrequired": "Email required",
"components.UserProfile.UserSettings.UserLinkedAccountsSettings.deleteFailed": "Unable to delete linked account.",
@@ -1519,8 +1516,11 @@
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingsfailed": "Web push notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.UserNotificationsWebPush.webpushsettingssaved": "Web push notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.deviceDefault": "Device Default",
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User ID",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account",
"components.UserProfile.UserSettings.UserNotificationSettings.discordId": "User IDs",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdAdd": "Add User ID",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdPlaceholder": "Discord User ID",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdRemove": "Remove",
"components.UserProfile.UserSettings.UserNotificationSettings.discordIdTip": "The <FindDiscordIdLink>multi-digit ID number</FindDiscordIdLink> associated with your user account. For multiple household accounts you can add more than one Discord user ID.",
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingsfailed": "Discord notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.discordsettingssaved": "Discord notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.email": "Email",
@@ -1549,7 +1549,7 @@
"components.UserProfile.UserSettings.UserNotificationSettings.telegramMessageThreadIdTip": "If your group-chat has topics enabled, you can specify a thread/topic's ID here",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingsfailed": "Telegram notification settings failed to save.",
"components.UserProfile.UserSettings.UserNotificationSettings.telegramsettingssaved": "Telegram notification settings saved successfully!",
"components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "You must provide a valid user ID",
"components.UserProfile.UserSettings.UserNotificationSettings.validationDiscordId": "Each ID must be a valid Discord user ID",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPgpPublicKey": "You must provide a valid PGP public key",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushbulletAccessToken": "You must provide an access token",
"components.UserProfile.UserSettings.UserNotificationSettings.validationPushoverApplicationToken": "You must provide a valid application token",