From 484975e3e0d72d133cc239882d9f1abeeefcb412 Mon Sep 17 00:00:00 2001 From: GordiNoki Date: Sat, 19 Oct 2024 15:13:52 +0300 Subject: [PATCH] feat(back): Discord webhooks for published maps --- .../src/app/config/config.interface.ts | 4 +- apps/backend/src/app/config/config.ts | 12 +- .../app/modules/maps/map-webhooks.service.ts | 168 ++++++++++++++++++ .../src/app/modules/maps/maps.module.ts | 8 +- .../src/app/modules/maps/maps.service.ts | 58 +++++- 5 files changed, 245 insertions(+), 5 deletions(-) create mode 100644 apps/backend/src/app/modules/maps/map-webhooks.service.ts diff --git a/apps/backend/src/app/config/config.interface.ts b/apps/backend/src/app/config/config.interface.ts index 1c862eb3d..9a3595f17 100644 --- a/apps/backend/src/app/config/config.interface.ts +++ b/apps/backend/src/app/config/config.interface.ts @@ -1,4 +1,5 @@ -import * as pino from 'pino'; +import { GamemodeCategory } from '@momentum/constants'; +import * as pino from 'pino'; export enum Environment { DEVELOPMENT = 'dev', @@ -48,5 +49,6 @@ export interface ConfigInterface { maxCreditsExceptTesters: number; preSignedUrlExpTime: number; }; + discordWebhooks: Record; logLevel: pino.LevelWithSilent; } diff --git a/apps/backend/src/app/config/config.ts b/apps/backend/src/app/config/config.ts index f06092ac3..99b52065e 100644 --- a/apps/backend/src/app/config/config.ts +++ b/apps/backend/src/app/config/config.ts @@ -12,8 +12,10 @@ JWT_WEB_EXPIRY_TIME, JWT_REFRESH_EXPIRY_TIME, MAX_MAP_IMAGE_SIZE, - PRE_SIGNED_URL_EXPIRE_TIME + PRE_SIGNED_URL_EXPIRE_TIME, + GamemodeCategory } from '@momentum/constants'; +import * as Enum from '@momentum/enum'; import { ConfigInterface, Environment } from './config.interface'; import * as process from 'node:process'; import * as pino from 'pino'; @@ -69,6 +71,14 @@ export const ConfigFactory = (): ConfigInterface => { maxCreditsExceptTesters: MAX_CREDITS_EXCEPT_TESTERS, preSignedUrlExpTime: PRE_SIGNED_URL_EXPIRE_TIME }, + discordWebhooks: Object.fromEntries( + Enum.values(GamemodeCategory).map((cat) => [ + cat, + isTest + ? '' + : (process.env[`DISCORD_WEBHOOK_${GamemodeCategory[cat]}_URL`] ?? '') + ]) + ) as Record, logLevel: (process.env['LOG_LEVEL'] ?? (isTest ? 'warn' : 'info')) as pino.LevelWithSilent }; diff --git a/apps/backend/src/app/modules/maps/map-webhooks.service.ts b/apps/backend/src/app/modules/maps/map-webhooks.service.ts new file mode 100644 index 000000000..9fcd7a107 --- /dev/null +++ b/apps/backend/src/app/modules/maps/map-webhooks.service.ts @@ -0,0 +1,168 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + Leaderboard, + MapCredit, + MapInfo, + MapSubmission, + MMap, + User +} from '@prisma/client'; +import { HttpService } from '@nestjs/axios'; +import { + MapSubmissionSuggestion, + GamemodeCategories, + GamemodeInfo, + TrackType, + imgLargePath, + MapCreditType, + MapSubmissionPlaceholder, + LeaderboardType, + Gamemode, + steamAvatarUrl +} from '@momentum/constants'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class MapWebhooksService { + constructor( + private config: ConfigService, + private http: HttpService + ) {} + + private readonly logger = new Logger('Discord Webhooks'); + + async sendPublicTestingDiscordEmbed( + extendedMap: MMap & { + info: MapInfo; + submission: MapSubmission; + submitter: User; + credits: Array; + } + ) { + const suggestions = + (extendedMap.submission + .suggestions as unknown as MapSubmissionSuggestion[]) ?? []; // TODO: #855 + + const placeholders = + (extendedMap.submission + .placeholders as unknown as MapSubmissionPlaceholder[]) ?? []; // TODO: #855 + + const mainTrackSuggestions = suggestions.filter( + ({ trackType }) => trackType === TrackType.MAIN + ); + + const mainTrackGamemodes = new Set( + mainTrackSuggestions.map(({ gamemode }) => gamemode) + ); + + const mapAuthors = [ + ...placeholders + .filter(({ type }) => type === MapCreditType.AUTHOR) + .map(({ alias }) => alias), + ...extendedMap.credits + .filter(({ type }) => type === MapCreditType.AUTHOR) + .map(({ user }) => user.alias) + ].sort(); + + const webhookBody = this.createMapUpdateWebhookBody( + 'A new map is open for public testing!', + extendedMap, + mapAuthors, + mainTrackSuggestions + ); + + await this.broadcastWebhookToCategories(mainTrackGamemodes, webhookBody); + } + + async sendApprovedDiscordEmbed( + extendedMap: MMap & { + info: MapInfo; + leaderboards: Array; + submitter: User; + credits: Array; + } + ) { + const mainTrackLeaderboards = extendedMap.leaderboards.filter( + ({ trackType, type }) => + trackType === TrackType.MAIN && type !== LeaderboardType.HIDDEN + ); + + const mainTrackGamemodes = new Set( + mainTrackLeaderboards.map(({ gamemode }) => gamemode) + ); + + const mapAuthors = extendedMap.credits + .filter(({ type }) => type === MapCreditType.AUTHOR) + .map(({ user }) => user.alias) + .sort(); + + const webhookBody = this.createMapUpdateWebhookBody( + 'A new map has been added!', + extendedMap, + mapAuthors, + mainTrackLeaderboards + ); + + await this.broadcastWebhookToCategories(mainTrackGamemodes, webhookBody); + } + + createMapUpdateWebhookBody( + text: string, + extendedMap: MMap & { info: MapInfo; submitter: User }, + authors: Array, + gamemodes: MapSubmissionSuggestion[] | Leaderboard[] + ) { + const frontendUrl = this.config.getOrThrow('url.frontend'); + const cdnUrl = this.config.getOrThrow('url.cdn'); + + return { + content: text, + embeds: [ + { + title: extendedMap.name, + description: 'By ' + authors.map((a) => `**${a}**`).join(', '), + url: `${frontendUrl}/maps/${extendedMap.id}`, + timestamp: extendedMap.info.creationDate.toISOString(), + color: 1611475, + image: { + url: `${cdnUrl}/${imgLargePath(extendedMap.images[0])}` + }, + fields: gamemodes.map( + (gm: MapSubmissionSuggestion | Leaderboard) => ({ + name: GamemodeInfo.get(gm.gamemode).name, + value: `Tier ${gm.tier}${gm.type === LeaderboardType.UNRANKED ? ' (Unranked)' : ''}`, + inline: true + }) + ), + footer: { + icon_url: steamAvatarUrl(extendedMap.submitter.avatar), + text: extendedMap.submitter.alias + } + } + ] + }; + } + + async broadcastWebhookToCategories( + gamemodes: Set, + webhookBody: object + ): Promise { + await Promise.all( + [...GamemodeCategories.entries()] + .filter(([, modes]) => modes.some((gm) => gamemodes.has(gm))) + .map(([category]) => { + const webhookUrl = this.config.getOrThrow( + `discordWebhooks.${category}` + ); + if (webhookUrl === '') return; + + return firstValueFrom( + this.http.post(webhookUrl, webhookBody, { + headers: { 'Content-Type': 'application/json' } + }) + ); + }) + ).catch((error) => this.logger.error('Failed to post to webhook', error)); + } +} diff --git a/apps/backend/src/app/modules/maps/maps.module.ts b/apps/backend/src/app/modules/maps/maps.module.ts index a16b94e4d..02877c6f2 100644 --- a/apps/backend/src/app/modules/maps/maps.module.ts +++ b/apps/backend/src/app/modules/maps/maps.module.ts @@ -13,6 +13,8 @@ import { MapTestInviteService } from './map-test-invite.service'; import { MapListService } from './map-list.service'; import { MapReviewModule } from '../map-review/map-review.module'; import { KillswitchModule } from '../killswitch/killswitch.module'; +import { MapWebhooksService } from './map-webhooks.service'; +import { HttpModule } from '@nestjs/axios'; @Module({ imports: [ @@ -23,7 +25,8 @@ import { KillswitchModule } from '../killswitch/killswitch.module'; forwardRef(() => RunsModule), forwardRef(() => AdminModule), forwardRef(() => MapReviewModule), - KillswitchModule + KillswitchModule, + HttpModule ], controllers: [MapsController], providers: [ @@ -31,7 +34,8 @@ import { KillswitchModule } from '../killswitch/killswitch.module'; MapCreditsService, MapImageService, MapTestInviteService, - MapListService + MapListService, + MapWebhooksService ], exports: [MapsService] }) diff --git a/apps/backend/src/app/modules/maps/maps.service.ts b/apps/backend/src/app/modules/maps/maps.service.ts index 5d5975810..37541cc49 100644 --- a/apps/backend/src/app/modules/maps/maps.service.ts +++ b/apps/backend/src/app/modules/maps/maps.service.ts @@ -91,6 +91,7 @@ import { import { MapListService } from './map-list.service'; import { MapReviewService } from '../map-review/map-review.service'; import { createHash } from 'node:crypto'; +import { MapWebhooksService } from './map-webhooks.service'; @Injectable() export class MapsService { @@ -105,7 +106,8 @@ export class MapsService { @Inject(forwardRef(() => MapReviewService)) private readonly mapReviewService: MapReviewService, private readonly adminActivityService: AdminActivityService, - private readonly mapListService: MapListService + private readonly mapListService: MapListService, + private readonly discordWebhookService: MapWebhooksService ) {} //#region Gets @@ -1064,6 +1066,33 @@ export class MapsService { ) as Prisma.MMapUpdateInput }); statusChanged = statusHandler[1] !== statusHandler[2]; + + // Ensure that discord notification will be sent after update + if (statusHandler[2] === MapStatus.PUBLIC_TESTING) { + const extendedMap = await tx.mMap.findUnique({ + where: { id: map.id }, + include: { + info: true, + submission: true, + submitter: true, + credits: { include: { user: true } } + } + }); + void this.discordWebhookService.sendPublicTestingDiscordEmbed( + extendedMap + ); + } else if (statusHandler[2] === MapStatus.APPROVED) { + const extendedMap = await tx.mMap.findUnique({ + where: { id: map.id }, + include: { + info: true, + leaderboards: true, + submitter: true, + credits: { include: { user: true } } + } + }); + void this.discordWebhookService.sendApprovedDiscordEmbed(extendedMap); + } } else { await tx.mMap.update({ where: { id: mapID }, @@ -1170,6 +1199,33 @@ export class MapsService { oldStatus = statusHandler[1]; newStatus = statusHandler[2]; + + // Ensure that discord notification will be sent after update + if (statusHandler[2] === MapStatus.PUBLIC_TESTING) { + const extendedMap = await tx.mMap.findUnique({ + where: { id: map.id }, + include: { + info: true, + submission: true, + submitter: true, + credits: { include: { user: true } } + } + }); + void this.discordWebhookService.sendPublicTestingDiscordEmbed( + extendedMap + ); + } else if (statusHandler[2] === MapStatus.APPROVED) { + const extendedMap = await tx.mMap.findUnique({ + where: { id: map.id }, + include: { + info: true, + leaderboards: true, + submitter: true, + credits: { include: { user: true } } + } + }); + void this.discordWebhookService.sendApprovedDiscordEmbed(extendedMap); + } } else { updatedMap = await tx.mMap.update({ where: { id: mapID },