diff --git a/bun.lockb b/bun.lockb index 467e84f..99b23de 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/projects/backend/package.json b/projects/backend/package.json index 1103ec6..d1a9a18 100644 --- a/projects/backend/package.json +++ b/projects/backend/package.json @@ -18,7 +18,7 @@ "@typescript-eslint/eslint-plugin": "^8.9.0", "@typescript-eslint/parser": "^8.9.0", "@vercel/og": "^0.6.3", - "discord-webhook-node": "^1.1.8", + "discordx": "^11.12.1", "elysia": "latest", "elysia-autoroutes": "^0.5.0", "elysia-decorators": "^1.0.2", diff --git a/projects/backend/src/bot/bot.ts b/projects/backend/src/bot/bot.ts new file mode 100644 index 0000000..49c4401 --- /dev/null +++ b/projects/backend/src/bot/bot.ts @@ -0,0 +1,53 @@ +import { Client, MetadataStorage } from "discordx"; +import { Config } from "@ssr/common/config"; +import { ActivityType, EmbedBuilder } from "discord.js"; + +export enum DiscordChannels { + trackedPlayerLogs = "1295985197262569512", + numberOneFeed = "1295988063817830430", + backendLogs = "1296524935237468250", +} + +export const DiscordBot = new Client({ + intents: [], + presence: { + status: "online", + activities: [ + { + name: "scores...", + type: ActivityType.Watching, + url: "https://ssr.fascinated.cc", + }, + ], + }, +}); + +DiscordBot.once("ready", () => { + console.log("Discord bot ready!"); +}); + +export function initDiscordBot() { + console.log("Initializing discord bot..."); + + MetadataStorage.instance.build().then(async () => { + await DiscordBot.login(Config.discordBotToken!).then(); + }); +} + +/** + * Logs the message to a discord channel. + * + * @param channelId the channel id to log to + * @param message the message to log + */ +export function logToChannel(channelId: DiscordChannels, message: EmbedBuilder) { + const channel = DiscordBot.channels.cache.find(c => c.id === channelId); + if (channel == undefined) { + throw new Error(`Channel "${channelId}" not found`); + } + if (!channel.isSendable()) { + throw new Error(`Channel "${channelId}" is not sendable`); + } + + channel.send({ embeds: [message] }); +} diff --git a/projects/backend/src/index.ts b/projects/backend/src/index.ts index f0b563c..462db9b 100644 --- a/projects/backend/src/index.ts +++ b/projects/backend/src/index.ts @@ -15,7 +15,7 @@ import PlayerController from "./controller/player.controller"; import { PlayerService } from "./service/player.service"; import { cron } from "@elysiajs/cron"; import { scoresaberService } from "@ssr/common/service/impl/scoresaber"; -import { delay } from "@ssr/common/utils/utils"; +import { delay, isProduction } from "@ssr/common/utils/utils"; import { connectScoreSaberWebSocket } from "@ssr/common/websocket/scoresaber-websocket"; import ImageController from "./controller/image.controller"; import ReplayController from "./controller/replay.controller"; @@ -24,6 +24,8 @@ import { Config } from "@ssr/common/config"; import { PlayerDocument, PlayerModel } from "@ssr/common/model/player"; import ScoresController from "./controller/scores.controller"; import LeaderboardController from "./controller/leaderboard.controller"; +import { DiscordChannels, initDiscordBot, logToChannel } from "./bot/bot"; +import { EmbedBuilder } from "discord.js"; // Load .env file dotenv.config({ @@ -40,6 +42,12 @@ connectScoreSaberWebSocket({ await PlayerService.trackScore(playerScore); await ScoreService.notifyNumberOne(playerScore); }, + onDisconnect: error => { + logToChannel( + DiscordChannels.backendLogs, + new EmbedBuilder().setDescription(`ScoreSaber websocket disconnected: ${error}`) + ); + }, }); export const app = new Elysia(); @@ -179,6 +187,9 @@ app.use(swagger()); app.onStart(() => { console.log("Listening on port http://localhost:8080"); + if (isProduction()) { + initDiscordBot(); + } }); app.listen(8080); diff --git a/projects/backend/src/service/player.service.ts b/projects/backend/src/service/player.service.ts index a703b96..88fa801 100644 --- a/projects/backend/src/service/player.service.ts +++ b/projects/backend/src/service/player.service.ts @@ -10,7 +10,8 @@ import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score import { MessageBuilder, Webhook } from "discord-webhook-node"; import { formatPp } from "@ssr/common/utils/number-utils"; import { isProduction } from "@ssr/common/utils/utils"; -import { Config } from "@ssr/common/config"; +import { DiscordChannels, logToChannel } from "../bot/bot"; +import { EmbedBuilder } from "discord.js"; export class PlayerService { /** @@ -43,19 +44,31 @@ export class PlayerService { // Only notify in production if (isProduction()) { - const hook = new Webhook({ - url: Config.trackedPlayerWebhook, - }); - hook.setUsername("Player Tracker"); - const embed = new MessageBuilder(); - embed.setTitle("New Player Tracked"); - embed.addField("Username", playerToken.name, true); - embed.addField("ID", playerToken.id, true); - embed.addField("PP", formatPp(playerToken.pp) + "pp", true); - embed.setDescription(`https://ssr.fascinated.cc/player/${playerToken.id}`); - embed.setThumbnail(playerToken.profilePicture); - embed.setColor("#00ff00"); - await hook.send(embed); + logToChannel( + DiscordChannels.trackedPlayerLogs, + new EmbedBuilder() + .setTitle("New Player Tracked") + .setDescription(`https://ssr.fascinated.cc/player/${playerToken.id}`) + .addFields([ + { + name: "Username", + value: playerToken.name, + inline: true, + }, + { + name: "ID", + value: playerToken.id, + inline: true, + }, + { + name: "PP", + value: formatPp(playerToken.pp) + "pp", + inline: true, + }, + ]) + .setThumbnail(playerToken.profilePicture) + .setColor("#00ff00") + ); } } catch (err) { const message = `Failed to create player document for "${id}"`; diff --git a/projects/backend/src/service/score.service.ts b/projects/backend/src/service/score.service.ts index 15571ae..6271939 100644 --- a/projects/backend/src/service/score.service.ts +++ b/projects/backend/src/service/score.service.ts @@ -4,7 +4,6 @@ import ScoreSaberPlayerScoreToken from "@ssr/common/types/token/scoresaber/score import { MessageBuilder, Webhook } from "discord-webhook-node"; import { formatPp } from "@ssr/common/utils/number-utils"; import { isProduction } from "@ssr/common/utils/utils"; -import { Config } from "@ssr/common/config"; import { Metadata } from "@ssr/common/types/metadata"; import { NotFoundError } from "elysia"; import BeatSaverService from "./beatsaver.service"; @@ -20,6 +19,8 @@ import { PlayerScore } from "@ssr/common/score/player-score"; import LeaderboardScoresResponse from "@ssr/common/response/leaderboard-scores-response"; import Score from "@ssr/common/score/score"; import PlayerScoresResponse from "@ssr/common/response/player-scores-response"; +import { DiscordChannels, logToChannel } from "../bot/bot"; +import { EmbedBuilder } from "discord.js"; export class ScoreService { /** @@ -45,20 +46,20 @@ export class ScoreService { return; } - const hook = new Webhook({ - url: Config.numberOneWebhook, - }); - hook.setUsername("Number One Feed"); - const embed = new MessageBuilder(); - embed.setTitle(`${player.name} set a #${score.rank} on ${leaderboard.songName} ${leaderboard.songSubName}`); - embed.setDescription(` - **Player:** https://ssr.fascinated.cc/player/${player.id} - **Leaderboard:** https://ssr.fascinated.cc/leaderboard/${leaderboard.id} - **PP:** ${formatPp(score.pp)} - `); - embed.setThumbnail(leaderboard.coverImage); - embed.setColor("#00ff00"); - await hook.send(embed); + logToChannel( + DiscordChannels.numberOneFeed, + new EmbedBuilder() + .setTitle(`${player.name} set a #1 on ${leaderboard.songName} ${leaderboard.songSubName}`) + .setDescription( + ` + **Player:** https://ssr.fascinated.cc/player/${player.id} + **Leaderboard:** https://ssr.fascinated.cc/leaderboard/${leaderboard.id} + **PP:** ${formatPp(score.pp)} + ` + ) + .setThumbnail(leaderboard.coverImage) + .setColor("#00ff00") + ); } /** diff --git a/projects/common/src/config.ts b/projects/common/src/config.ts index 02a1b56..d22437f 100644 --- a/projects/common/src/config.ts +++ b/projects/common/src/config.ts @@ -11,4 +11,5 @@ export const Config = { trackedPlayerWebhook: process.env.TRACKED_PLAYERS_WEBHOOK, numberOneWebhook: process.env.NUMBER_ONE_WEBHOOK, mongoUri: process.env.MONGO_URI, + discordBotToken: process.env.DISCORD_BOT_TOKEN, } as const; diff --git a/projects/common/src/websocket/scoresaber-websocket.ts b/projects/common/src/websocket/scoresaber-websocket.ts index f95b611..f24101e 100644 --- a/projects/common/src/websocket/scoresaber-websocket.ts +++ b/projects/common/src/websocket/scoresaber-websocket.ts @@ -5,22 +5,29 @@ type ScoresaberSocket = { /** * Invoked when a general message is received. * - * @param message The received message. + * @param message the received message. */ onMessage?: (message: unknown) => void; /** * Invoked when a score message is received. * - * @param score The received score data. + * @param score the received score data. */ onScore?: (score: ScoreSaberPlayerScoreToken) => void; + + /** + * Invoked when the connection is closed. + * + * @param error the error that caused the connection to close + */ + onDisconnect?: (error: WebSocket.ErrorEvent) => void; }; /** * Connects to the ScoreSaber websocket and handles incoming messages. */ -export function connectScoreSaberWebSocket({ onMessage, onScore }: ScoresaberSocket) { +export function connectScoreSaberWebSocket({ onMessage, onScore, onDisconnect }: ScoresaberSocket) { let websocket: WebSocket | null = null; function connectWs() { @@ -35,6 +42,8 @@ export function connectScoreSaberWebSocket({ onMessage, onScore }: ScoresaberSoc if (websocket) { websocket.close(); // Close the connection on error } + + onDisconnect && onDisconnect(error); }; websocket.onclose = () => {