Skip to content

Commit 63175f5

Browse files
authored
feat(notifications): support multiple Discord IDs (#2712)
1 parent 5fc7a40 commit 63175f5

20 files changed

Lines changed: 279 additions & 157 deletions

File tree

docs/using-seerr/notifications/webhook.md

Lines changed: 32 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -53,13 +53,13 @@ Customize the JSON payload to suit your needs. Seerr provides several [template
5353

5454
These variables are for the target recipient of the notification.
5555

56-
| Variable | Value |
57-
| ---------------------------------------- | ------------------------------------------------------------- |
58-
| `{{notifyuser_username}}` | The target notification recipient's username |
59-
| `{{notifyuser_email}}` | The target notification recipient's email address |
60-
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
61-
| `{{notifyuser_settings_discordId}}` | The target notification recipient's Discord ID (if set) |
62-
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
56+
| Variable | Value |
57+
| ---------------------------------------- | -------------------------------------------------------------------------------------------- |
58+
| `{{notifyuser_username}}` | The target notification recipient's username |
59+
| `{{notifyuser_email}}` | The target notification recipient's email address |
60+
| `{{notifyuser_avatar}}` | The target notification recipient's avatar URL |
61+
| `{{notifyuser_settings_discordIds}}` | The target notification recipient's Discord ID(s) as a JSON array (if set) |
62+
| `{{notifyuser_settings_telegramChatId}}` | The target notification recipient's Telegram Chat ID (if set) |
6363

6464
:::info
6565
The `notifyuser` variables are not defined for the following request notification types, as they are intended for application administrators rather than end users:
@@ -113,42 +113,42 @@ The `{{request}}` will be `null` if there is no relevant media object for the no
113113

114114
The following special variables are only included in request-related notifications.
115115

116-
| Variable | Value |
117-
| ----------------------------------------- | ----------------------------------------------- |
118-
| `{{request_id}}` | The request ID |
119-
| `{{requestedBy_username}}` | The requesting user's username |
120-
| `{{requestedBy_email}}` | The requesting user's email address |
121-
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
122-
| `{{requestedBy_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
123-
| `{{requestedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
124-
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
116+
| Variable | Value |
117+
| ----------------------------------------- | ------------------------------------------------------------------------------ |
118+
| `{{request_id}}` | The request ID |
119+
| `{{requestedBy_username}}` | The requesting user's username |
120+
| `{{requestedBy_email}}` | The requesting user's email address |
121+
| `{{requestedBy_avatar}}` | The requesting user's avatar URL |
122+
| `{{requestedBy_jellyfinUserId}}` | The requesting user's Jellyfin User ID |
123+
| `{{requestedBy_settings_discordIds}}` | The requesting user's Discord ID(s) as a JSON array (if set) |
124+
| `{{requestedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
125125

126126
#### Issue
127127

128128
The `{{issue}}` will be `null` if there is no relevant media object for the notification.
129129

130130
The following special variables are only included in issue-related notifications.
131131

132-
| Variable | Value |
133-
| ---------------------------------------- | ----------------------------------------------- |
134-
| `{{issue_id}}` | The issue ID |
135-
| `{{reportedBy_username}}` | The requesting user's username |
136-
| `{{reportedBy_email}}` | The requesting user's email address |
137-
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
138-
| `{{reportedBy_settings_discordId}}` | The requesting user's Discord ID (if set) |
139-
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
132+
| Variable | Value |
133+
| ---------------------------------------- | ------------------------------------------------------------------------------ |
134+
| `{{issue_id}}` | The issue ID |
135+
| `{{reportedBy_username}}` | The requesting user's username |
136+
| `{{reportedBy_email}}` | The requesting user's email address |
137+
| `{{reportedBy_avatar}}` | The requesting user's avatar URL |
138+
| `{{reportedBy_settings_discordIds}}` | The reporting user's Discord ID(s) as a JSON array (if set) |
139+
| `{{reportedBy_settings_telegramChatId}}` | The requesting user's Telegram Chat ID (if set) |
140140

141141
#### Comment
142142

143143
The `{{comment}}` will be `null` if there is no relevant media object for the notification.
144144

145145
The following special variables are only included in issue comment-related notifications.
146146

147-
| Variable | Value |
148-
| ----------------------------------------- | ----------------------------------------------- |
149-
| `{{comment_message}}` | The comment message |
150-
| `{{commentedBy_username}}` | The commenting user's username |
151-
| `{{commentedBy_email}}` | The commenting user's email address |
152-
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
153-
| `{{commentedBy_settings_discordId}}` | The commenting user's Discord ID (if set) |
154-
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |
147+
| Variable | Value |
148+
| ----------------------------------------- | ------------------------------------------------------------------------------ |
149+
| `{{comment_message}}` | The comment message |
150+
| `{{commentedBy_username}}` | The commenting user's username |
151+
| `{{commentedBy_email}}` | The commenting user's email address |
152+
| `{{commentedBy_avatar}}` | The commenting user's avatar URL |
153+
| `{{commentedBy_settings_discordIds}}` | The commenting user's Discord ID(s) as a JSON array (if set) |
154+
| `{{commentedBy_settings_telegramChatId}}` | The commenting user's Telegram Chat ID (if set) |

seerr-api.yml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,6 @@ components:
148148
email:
149149
type: string
150150
example: 'user@example.com'
151-
discordId:
152-
type: string
153-
nullable: true
154-
example: '123456789'
155151
locale:
156152
type: string
157153
nullable: true
@@ -1956,8 +1952,10 @@ components:
19561952
discordEnabledTypes:
19571953
type: number
19581954
nullable: true
1959-
discordId:
1960-
type: string
1955+
discordIds:
1956+
type: array
1957+
items:
1958+
type: string
19611959
nullable: true
19621960
pushbulletAccessToken:
19631961
type: string

server/constants/discord.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export const DISCORD_SNOWFLAKE_REGEX = /^\d{17,20}$/;
2+
3+
export enum EmbedColors {
4+
DEFAULT = 0,
5+
AQUA = 1752220,
6+
GREEN = 3066993,
7+
BLUE = 3447003,
8+
PURPLE = 10181046,
9+
GOLD = 15844367,
10+
ORANGE = 15105570,
11+
RED = 15158332,
12+
GREY = 9807270,
13+
DARKER_GREY = 8359053,
14+
NAVY = 3426654,
15+
DARK_AQUA = 1146986,
16+
DARK_GREEN = 2067276,
17+
DARK_BLUE = 2123412,
18+
DARK_PURPLE = 7419530,
19+
DARK_GOLD = 12745742,
20+
DARK_ORANGE = 11027200,
21+
DARK_RED = 10038562,
22+
DARK_GREY = 9936031,
23+
LIGHT_GREY = 12370112,
24+
DARK_NAVY = 2899536,
25+
LUMINOUS_VIVID_PINK = 16580705,
26+
DARK_VIVID_PINK = 12320855,
27+
}

server/entity/UserSettings.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ export const ALL_NOTIFICATIONS = Object.values(Notification)
1414
.filter((v) => !isNaN(Number(v)))
1515
.reduce((a, v) => a + Number(v), 0);
1616

17+
// convert between DB representation (JSON string) into typescript array
18+
const jsonArrayTransformer = {
19+
from: (v: string | null): string[] => {
20+
try {
21+
return v ? JSON.parse(v) : [];
22+
} catch {
23+
return [];
24+
}
25+
},
26+
to: (v: string[] | null): string | null =>
27+
v?.length ? JSON.stringify(v) : null,
28+
};
29+
1730
@Entity()
1831
export class UserSettings {
1932
constructor(init?: Partial<UserSettings>) {
@@ -42,8 +55,8 @@ export class UserSettings {
4255
@Column({ nullable: true })
4356
public pgpKey?: string;
4457

45-
@Column({ nullable: true })
46-
public discordId?: string;
58+
@Column({ type: 'text', nullable: true, transformer: jsonArrayTransformer })
59+
public discordIds: string[];
4760

4861
@Column({ nullable: true })
4962
public pushbulletAccessToken?: string;

server/interfaces/api/userSettingsInterfaces.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { NotificationAgentKey } from '@server/lib/settings';
33
export interface UserSettingsGeneralResponse {
44
username?: string;
55
email?: string;
6-
discordId?: string;
76
locale?: string;
87
discoverRegion?: string;
98
streamingRegion?: string;
@@ -26,7 +25,7 @@ export interface UserSettingsNotificationsResponse {
2625
pgpKey?: string;
2726
discordEnabled?: boolean;
2827
discordEnabledTypes?: number;
29-
discordId?: string;
28+
discordIds?: string[];
3029
pushbulletAccessToken?: string;
3130
pushoverApplicationToken?: string;
3231
pushoverUserKey?: string;

server/lib/notifications/agents/discord.ts

Lines changed: 32 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
DISCORD_SNOWFLAKE_REGEX,
3+
EmbedColors,
4+
} from '@server/constants/discord';
15
import { IssueStatus, IssueTypeName } from '@server/constants/issue';
26
import { getRepository } from '@server/datasource';
37
import { User } from '@server/entity/User';
@@ -16,31 +20,8 @@ import {
1620
import type { NotificationAgent, NotificationPayload } from './agent';
1721
import { BaseAgent } from './agent';
1822

19-
enum EmbedColors {
20-
DEFAULT = 0,
21-
AQUA = 1752220,
22-
GREEN = 3066993,
23-
BLUE = 3447003,
24-
PURPLE = 10181046,
25-
GOLD = 15844367,
26-
ORANGE = 15105570,
27-
RED = 15158332,
28-
GREY = 9807270,
29-
DARKER_GREY = 8359053,
30-
NAVY = 3426654,
31-
DARK_AQUA = 1146986,
32-
DARK_GREEN = 2067276,
33-
DARK_BLUE = 2123412,
34-
DARK_PURPLE = 7419530,
35-
DARK_GOLD = 12745742,
36-
DARK_ORANGE = 11027200,
37-
DARK_RED = 10038562,
38-
DARK_GREY = 9936031,
39-
LIGHT_GREY = 12370112,
40-
DARK_NAVY = 2899536,
41-
LUMINOUS_VIVID_PINK = 16580705,
42-
DARK_VIVID_PINK = 12320855,
43-
}
23+
const isValidSnowflake = (id: string): boolean =>
24+
DISCORD_SNOWFLAKE_REGEX.test(id);
4425

4526
interface DiscordImageEmbed {
4627
url?: string;
@@ -278,9 +259,12 @@ class DiscordAgent
278259
NotificationAgentKey.DISCORD,
279260
type
280261
) &&
281-
payload.notifyUser.settings.discordId
262+
payload.notifyUser.settings.discordIds?.length
282263
) {
283-
userMentions.push(`<@${payload.notifyUser.settings.discordId}>`);
264+
const validIds = payload.notifyUser.settings.discordIds.filter(
265+
(id) => isValidSnowflake(id)
266+
);
267+
userMentions.push(...validIds.map((id) => `<@${id}>`));
284268
}
285269
}
286270

@@ -296,16 +280,30 @@ class DiscordAgent
296280
NotificationAgentKey.DISCORD,
297281
type
298282
) &&
299-
user.settings.discordId &&
283+
user.settings.discordIds?.length &&
300284
shouldSendAdminNotification(type, user, payload)
301285
)
302-
.map((user) => `<@${user.settings?.discordId}>`)
286+
.flatMap((user) =>
287+
user
288+
.settings!.discordIds.filter((id) => isValidSnowflake(id))
289+
.map((id) => `<@${id}>`)
290+
)
303291
);
304292
}
305293
}
306294

307-
if (settings.options.webhookRoleId) {
295+
const allowedUserIds = userMentions.map((mention) =>
296+
mention.replace(/[<@>]/g, '')
297+
);
298+
299+
const allowedRoleIds: string[] = [];
300+
301+
if (
302+
settings.options.webhookRoleId &&
303+
isValidSnowflake(settings.options.webhookRoleId)
304+
) {
308305
userMentions.push(`<@&${settings.options.webhookRoleId}>`);
306+
allowedRoleIds.push(settings.options.webhookRoleId);
309307
}
310308

311309
// Discord webhooks go to a channel, not per-user,
@@ -322,6 +320,10 @@ class DiscordAgent
322320
avatar_url: settings.options.botAvatarUrl,
323321
embeds: [this.buildEmbed(type, payload, locale)],
324322
content: userMentions.join(' '),
323+
allowed_mentions: {
324+
users: allowedUserIds,
325+
roles: allowedRoleIds,
326+
},
325327
} as DiscordWebhookPayload);
326328

327329
return true;

server/lib/notifications/agents/webhook.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
2323
notifyuser_username: 'notifyUser.displayName',
2424
notifyuser_email: 'notifyUser.email',
2525
notifyuser_avatar: 'notifyUser.avatar',
26-
notifyuser_settings_discordId: 'notifyUser.settings.discordId',
26+
notifyuser_settings_discordIds: 'notifyUser.settings.discordIds',
2727
notifyuser_settings_telegramChatId: 'notifyUser.settings.telegramChatId',
2828
media_imdbid: 'media.imdbId',
2929
media_tmdbid: 'media.tmdbId',
@@ -42,7 +42,7 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
4242
requestedBy_username: 'request.requestedBy.displayName',
4343
requestedBy_email: 'request.requestedBy.email',
4444
requestedBy_avatar: 'request.requestedBy.avatar',
45-
requestedBy_settings_discordId: 'request.requestedBy.settings.discordId',
45+
requestedBy_settings_discordIds: 'request.requestedBy.settings.discordIds',
4646
requestedBy_settings_telegramChatId:
4747
'request.requestedBy.settings.telegramChatId',
4848
issue_id: 'issue.id',
@@ -53,13 +53,13 @@ const KeyMap: Record<string, string | KeyMapFunction> = {
5353
reportedBy_username: 'issue.createdBy.displayName',
5454
reportedBy_email: 'issue.createdBy.email',
5555
reportedBy_avatar: 'issue.createdBy.avatar',
56-
reportedBy_settings_discordId: 'issue.createdBy.settings.discordId',
56+
reportedBy_settings_discordIds: 'issue.createdBy.settings.discordIds',
5757
reportedBy_settings_telegramChatId: 'issue.createdBy.settings.telegramChatId',
5858
comment_message: 'comment.message',
5959
commentedBy_username: 'comment.user.displayName',
6060
commentedBy_email: 'comment.user.email',
6161
commentedBy_avatar: 'comment.user.avatar',
62-
commentedBy_settings_discordId: 'comment.user.settings.discordId',
62+
commentedBy_settings_discordIds: 'comment.user.settings.discordIds',
6363
commentedBy_settings_telegramChatId: 'comment.user.settings.telegramChatId',
6464
};
6565

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { MigrationInterface, QueryRunner } from 'typeorm';
2+
3+
export class AddDiscordIdsColumn1779783365432 implements MigrationInterface {
4+
name = 'AddDiscordIdsColumn1779783365432';
5+
6+
public async up(queryRunner: QueryRunner): Promise<void> {
7+
await queryRunner.query(
8+
`ALTER TABLE "user_settings" ADD "discordIds" text`
9+
);
10+
// same for postgres (convert existing single ID into list with one entry)
11+
await queryRunner.query(
12+
`UPDATE "user_settings" SET "discordIds" = '["' || "discordId" || '"]' WHERE "discordId" IS NOT NULL AND "discordId" != ''`
13+
);
14+
await queryRunner.query(
15+
`ALTER TABLE "user_settings" DROP COLUMN "discordId"`
16+
);
17+
}
18+
19+
public async down(queryRunner: QueryRunner): Promise<void> {
20+
await queryRunner.query(
21+
`ALTER TABLE "user_settings" ADD "discordId" character varying`
22+
);
23+
await queryRunner.query(
24+
`UPDATE "user_settings" SET "discordId" = ("discordIds"::jsonb ->> 0) WHERE "discordIds" IS NOT NULL AND "discordIds" != ''`
25+
);
26+
await queryRunner.query(
27+
`ALTER TABLE "user_settings" DROP COLUMN "discordIds"`
28+
);
29+
}
30+
}

0 commit comments

Comments
 (0)